From ea3901d8b03c7869887687635202ec3f702e0368 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 15 Jul 2026 14:07:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=A7=9F=E6=88=B7=E5=9D=90?= =?UTF-8?q?=E5=B8=AD=E8=B4=A6=E5=8F=B7=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 /api/staff 列表/创建/更新/禁用,按租户坐席配额校验;系统设置增加坐席账号页支持增改禁与配额展示。 --- server/internal/handler/router.go | 8 + server/internal/handler/staff.go | 355 ++++++++++++++++++++++++++++++ web/src/pages/agent/Settings.tsx | 327 +++++++++++++++++++++++++-- web/src/services/api.ts | 22 ++ 4 files changed, 696 insertions(+), 16 deletions(-) create mode 100644 server/internal/handler/staff.go diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 25eb8e6..e6207e5 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -16,6 +16,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S admin := NewAdminHandler() channel := NewChannelHandler() settings := NewSettingsHandler() + staff := NewStaffHandler() ws := NewWsHandler() widget := NewWidgetHandler() upload := NewUploadHandler(store, storageCfg) @@ -94,6 +95,13 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S settingsGroup.GET("", settings.Get) settingsGroup.PUT("", settings.Update) + // 坐席账号(租户内) + staffGroup := authRequired.Group("/staff") + staffGroup.GET("", staff.List) + staffGroup.POST("", staff.Create) + staffGroup.PUT("/:id", staff.Update) + staffGroup.DELETE("/:id", staff.Delete) + // 统计 statistics := authRequired.Group("/statistics") statistics.GET("/kpi", stats.KPIs) diff --git a/server/internal/handler/staff.go b/server/internal/handler/staff.go new file mode 100644 index 0000000..3dd8cea --- /dev/null +++ b/server/internal/handler/staff.go @@ -0,0 +1,355 @@ +package handler + +import ( + "net/http" + "strings" + "unicode/utf8" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "kefu-sys/server/internal/middleware" + "kefu-sys/server/internal/model" +) + +type StaffHandler struct{} + +func NewStaffHandler() *StaffHandler { return &StaffHandler{} } + +func requireStaffManager(c *gin.Context) bool { + if middleware.HasAnyRole(c, "admin") { + return true + } + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可管理坐席账号"}) + return false +} + +// 坐席占用:本租户下 agent / supervisor / admin 均计 1 席(不含 disabled) +func countActiveSeats(tenantID uint) (int64, error) { + var n int64 + err := model.DB.Model(&model.User{}). + Where("tenant_id = ? AND role IN ? AND status <> ?", tenantID, []string{"agent", "supervisor", "admin"}, "disabled"). + Count(&n).Error + return n, err +} + +func loadTenantSeatLimit(tenantID uint) (int, error) { + var tenant model.Tenant + if err := model.DB.Select("id", "seat_count").First(&tenant, tenantID).Error; err != nil { + return 0, err + } + if tenant.SeatCount <= 0 { + return 2, nil + } + return tenant.SeatCount, nil +} + +type StaffItem struct { + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Role string `json:"role"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + LastOnlineAt string `json:"last_online_at,omitempty"` +} + +func (h *StaffHandler) List(c *gin.Context) { + if !middleware.HasAnyRole(c, "admin", "supervisor") { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看坐席列表"}) + return + } + tenantID := middleware.GetTenantID(c) + var users []model.User + if err := model.DB.Where("tenant_id = ? AND role IN ?", tenantID, []string{"agent", "supervisor", "admin"}). + Order("role asc, id asc").Find(&users).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询坐席失败"}) + return + } + seatLimit, _ := loadTenantSeatLimit(tenantID) + used, _ := countActiveSeats(tenantID) + + items := make([]StaffItem, 0, len(users)) + for _, u := range users { + item := StaffItem{ + ID: u.ID, Username: u.Username, Nickname: u.Nickname, + Role: u.Role, Status: u.Status, + CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"), + } + if u.LastOnlineAt != nil { + item.LastOnlineAt = u.LastOnlineAt.Format("2006-01-02 15:04:05") + } + items = append(items, item) + } + middleware.JSON(c, gin.H{ + "list": items, + "seat_limit": seatLimit, + "seat_used": used, + }) +} + +type CreateStaffReq struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Nickname string `json:"nickname"` + Role string `json:"role"` +} + +func (h *StaffHandler) Create(c *gin.Context) { + if !requireStaffManager(c) { + return + } + var req CreateStaffReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + username := strings.TrimSpace(req.Username) + if utf8.RuneCountInString(username) < 3 || utf8.RuneCountInString(username) > 30 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "用户名需 3-30 个字符"}) + return + } + if len(req.Password) < 6 || len(req.Password) > 64 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "密码需 6-64 位"}) + return + } + role := req.Role + if role == "" { + role = "agent" + } + if role != "agent" && role != "supervisor" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅可创建客服或主管账号"}) + return + } + nickname := strings.TrimSpace(req.Nickname) + if nickname == "" { + nickname = username + } + if utf8.RuneCountInString(nickname) > 50 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "昵称过长"}) + return + } + + tenantID := middleware.GetTenantID(c) + seatLimit, err := loadTenantSeatLimit(tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取坐席配额失败"}) + return + } + used, err := countActiveSeats(tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "统计坐席失败"}) + return + } + if used >= int64(seatLimit) { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "坐席数已达上限,请升级套餐或增加坐席"}) + return + } + + var exists int64 + model.DB.Model(&model.User{}).Where("username = ?", username).Count(&exists) + if exists > 0 { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "用户名已存在"}) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) + return + } + user := model.User{ + TenantID: tenantID, Role: role, Username: username, + PasswordHash: string(hash), Nickname: nickname, Status: "offline", + } + if err := model.DB.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) + return + } + + model.DB.Create(&model.OperationLog{ + OperatorID: middleware.GetUserID(c), + Action: "create_staff", + Detail: "创建坐席: " + username + " (" + role + ")", + TargetType: "user", + TargetID: &user.ID, + IP: c.ClientIP(), + }) + + middleware.JSON(c, StaffItem{ + ID: user.ID, Username: user.Username, Nickname: user.Nickname, + Role: user.Role, Status: user.Status, + CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), + }) +} + +type UpdateStaffReq struct { + Nickname *string `json:"nickname"` + Role *string `json:"role"` + Status *string `json:"status"` + Password *string `json:"password"` +} + +func (h *StaffHandler) Update(c *gin.Context) { + if !requireStaffManager(c) { + return + } + tenantID := middleware.GetTenantID(c) + id := c.Param("id") + var user model.User + if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "账号不存在"}) + return + } + if user.Role == "platform_admin" { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无法操作该账号"}) + return + } + + var req UpdateStaffReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + + // 不可降级/删除最后一个 admin:若改角色离开 admin,检查剩余 admin + updates := map[string]interface{}{} + if req.Nickname != nil { + n := strings.TrimSpace(*req.Nickname) + if n == "" || utf8.RuneCountInString(n) > 50 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "昵称无效"}) + return + } + updates["nickname"] = n + } + if req.Role != nil { + r := *req.Role + if r != "agent" && r != "supervisor" && r != "admin" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色无效"}) + return + } + // 非当前登录用户不能随意把自己改没 admin:禁止把最后一个 admin 改成非 admin + if user.Role == "admin" && r != "admin" { + var adminCnt int64 + model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ?", tenantID, "admin", "disabled").Count(&adminCnt) + if adminCnt <= 1 { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "至少保留一名启用的租户管理员"}) + return + } + } + // 普通管理员不能创建/提升为 admin?允许 admin 设置另一 admin + updates["role"] = r + } + if req.Status != nil { + s := *req.Status + if s != "online" && s != "offline" && s != "busy" && s != "disabled" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "状态无效"}) + return + } + // 禁用时检查坐席释放;启用时检查配额 + if s == "disabled" && user.Status != "disabled" { + if user.Role == "admin" { + var adminCnt int64 + model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ? AND id <> ?", tenantID, "admin", "disabled", user.ID).Count(&adminCnt) + if adminCnt < 1 { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "不能禁用唯一的租户管理员"}) + return + } + } + if user.ID == middleware.GetUserID(c) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不能禁用当前登录账号"}) + return + } + } + if s != "disabled" && user.Status == "disabled" { + seatLimit, _ := loadTenantSeatLimit(tenantID) + used, _ := countActiveSeats(tenantID) + if used >= int64(seatLimit) { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "坐席数已达上限,无法启用"}) + return + } + } + updates["status"] = s + } + if req.Password != nil && *req.Password != "" { + if len(*req.Password) < 6 || len(*req.Password) > 64 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "密码需 6-64 位"}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新密码失败"}) + return + } + updates["password_hash"] = string(hash) + } + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) + return + } + + if err := model.DB.Model(&user).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) + return + } + model.DB.First(&user, user.ID) + + model.DB.Create(&model.OperationLog{ + OperatorID: middleware.GetUserID(c), + Action: "update_staff", + Detail: "更新坐席: " + user.Username, + TargetType: "user", + TargetID: &user.ID, + IP: c.ClientIP(), + }) + + item := StaffItem{ + ID: user.ID, Username: user.Username, Nickname: user.Nickname, + Role: user.Role, Status: user.Status, + CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), + } + if user.LastOnlineAt != nil { + item.LastOnlineAt = user.LastOnlineAt.Format("2006-01-02 15:04:05") + } + middleware.JSON(c, item) +} + +func (h *StaffHandler) Delete(c *gin.Context) { + if !requireStaffManager(c) { + return + } + tenantID := middleware.GetTenantID(c) + id := c.Param("id") + var user model.User + if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "账号不存在"}) + return + } + if user.ID == middleware.GetUserID(c) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不能删除当前登录账号"}) + return + } + if user.Role == "admin" { + var adminCnt int64 + model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ?", tenantID, "admin", "disabled").Count(&adminCnt) + if adminCnt <= 1 && user.Status != "disabled" { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "不能删除唯一的租户管理员"}) + return + } + } + + // 软禁用:保留数据关联;物理删除仅对无会话绑定的账号可选——统一用 disabled 更安全 + if err := model.DB.Model(&user).Update("status", "disabled").Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "禁用失败"}) + return + } + + model.DB.Create(&model.OperationLog{ + OperatorID: middleware.GetUserID(c), + Action: "disable_staff", + Detail: "禁用坐席: " + user.Username, + TargetType: "user", + TargetID: &user.ID, + IP: c.ClientIP(), + }) + middleware.JSON(c, gin.H{"message": "已禁用"}) +} diff --git a/web/src/pages/agent/Settings.tsx b/web/src/pages/agent/Settings.tsx index 97f88ff..0a07b0d 100644 --- a/web/src/pages/agent/Settings.tsx +++ b/web/src/pages/agent/Settings.tsx @@ -1,14 +1,17 @@ import { useEffect, useState, type ReactNode } from 'react' -import { Form, Input, Switch, Select, Button, message, Spin, Empty } from 'antd' +import { Form, Input, Switch, Select, Button, message, Spin, Empty, Modal, Popconfirm } from 'antd' import { LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined, - PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, SafetyCertificateOutlined, + PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, UserSwitchOutlined, MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined, + EditOutlined, StopOutlined, CheckCircleOutlined, } from '@ant-design/icons' import { - createChannel, getChannels, getTenantSettings, updateChannel, updateTenantSettings, - type Channel, type TenantSettings, type WorkHours, + createChannel, createStaff, deleteStaff, getChannels, getStaff, getTenantSettings, + updateChannel, updateStaff, updateTenantSettings, + type Channel, type StaffUser, type TenantSettings, type WorkHours, } from '@/services/api' +import { useAuth } from '@/stores/auth' const typeMeta: Record = { web: { icon: , label: '网页聊天', desc: '嵌入官网或任意网页的在线客服窗口' }, @@ -35,22 +38,40 @@ const hourOptions = [ { value: '全天', label: '全天' }, ] +const roleLabel: Record = { + admin: '管理员', + supervisor: '主管', + agent: '客服', +} + +const statusLabel: Record = { + online: { text: '在线', className: 'bg-emerald-50 text-emerald-700' }, + offline: { text: '离线', className: 'bg-neutral-100 text-neutral-600' }, + busy: { text: '忙碌', className: 'bg-amber-50 text-amber-700' }, + disabled: { text: '已禁用', className: 'bg-red-50 text-red-600' }, +} + const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[] = [ { key: 'basic', label: '基本设置', desc: '租户展示名称、默认昵称与时区', icon: }, { key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: }, + { key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: }, { key: 'assignment', label: '客服分配规则', desc: '会话自动分配策略说明', icon: }, - { key: 'permission', label: '权限管理', desc: '角色与能力说明', icon: }, { key: 'autoreply', label: '自动回复', desc: '欢迎语与离线留言提示', icon: }, { key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: }, { key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: }, ] const Settings = () => { + const { user } = useAuth() + const isAdmin = user?.role === 'admin' + const canViewStaff = user?.role === 'admin' || user?.role === 'supervisor' + const [activeTab, setActiveTab] = useState('basic') const [basicForm] = Form.useForm() const [autoReplyForm] = Form.useForm() const [workForm] = Form.useForm() const [notifyForm] = Form.useForm() + const [staffForm] = Form.useForm() const [channels, setChannels] = useState([]) const [settings, setSettings] = useState(null) const [loadingChannels, setLoadingChannels] = useState(false) @@ -58,8 +79,31 @@ const Settings = () => { const [saving, setSaving] = useState(false) const [togglingId, setTogglingId] = useState(null) + const [staffList, setStaffList] = useState([]) + const [seatLimit, setSeatLimit] = useState(0) + const [seatUsed, setSeatUsed] = useState(0) + const [loadingStaff, setLoadingStaff] = useState(false) + const [staffModalOpen, setStaffModalOpen] = useState(false) + const [editingStaff, setEditingStaff] = useState(null) + const [savingStaff, setSavingStaff] = useState(false) + const currentTab = tabItems.find(t => t.key === activeTab) || tabItems[0] + const loadStaff = async () => { + setLoadingStaff(true) + try { + const res = await getStaff() + setStaffList(Array.isArray(res.data?.list) ? res.data.list : []) + setSeatLimit(res.data?.seat_limit ?? 0) + setSeatUsed(res.data?.seat_used ?? 0) + } catch (e) { + setStaffList([]) + message.error(e instanceof Error ? e.message : '加载坐席失败') + } finally { + setLoadingStaff(false) + } + } + const loadChannels = async () => { setLoadingChannels(true) try { @@ -108,8 +152,79 @@ const Settings = () => { useEffect(() => { if (activeTab === 'channels') loadChannels() if (['basic', 'autoreply', 'worktime', 'notify'].includes(activeTab)) loadSettings() + if (activeTab === 'staff' && canViewStaff) loadStaff() }, [activeTab]) + const openCreateStaff = () => { + setEditingStaff(null) + staffForm.resetFields() + staffForm.setFieldsValue({ role: 'agent' }) + setStaffModalOpen(true) + } + + const openEditStaff = (s: StaffUser) => { + setEditingStaff(s) + staffForm.resetFields() + staffForm.setFieldsValue({ + nickname: s.nickname, + role: s.role === 'admin' ? 'admin' : s.role, + status: s.status, + password: undefined, + }) + setStaffModalOpen(true) + } + + const handleSaveStaff = async (values: { + username?: string; password?: string; nickname?: string; role?: string; status?: string + }) => { + setSavingStaff(true) + try { + if (editingStaff) { + await updateStaff(editingStaff.id, { + nickname: values.nickname, + role: values.role === 'admin' ? 'admin' : values.role, + status: values.status, + password: values.password || undefined, + }) + message.success('坐席已更新') + } else { + await createStaff({ + username: values.username!.trim(), + password: values.password!, + nickname: values.nickname?.trim(), + role: values.role || 'agent', + }) + message.success('坐席已创建') + } + setStaffModalOpen(false) + await loadStaff() + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败') + } finally { + setSavingStaff(false) + } + } + + const handleDisableStaff = async (s: StaffUser) => { + try { + await deleteStaff(s.id) + message.success('已禁用') + await loadStaff() + } catch (e) { + message.error(e instanceof Error ? e.message : '操作失败') + } + } + + const handleEnableStaff = async (s: StaffUser) => { + try { + await updateStaff(s.id, { status: 'offline' }) + message.success('已启用') + await loadStaff() + } catch (e) { + message.error(e instanceof Error ? e.message : '启用失败') + } + } + const savePartial = async (payload: Parameters[0], okText = '已保存') => { setSaving(true) try { @@ -226,10 +341,22 @@ const Settings = () => {

{currentTab.label}

{panelHeaderAction} + {activeTab === 'staff' && isAdmin && ( + + )}
-
+

{currentTab.desc}

{activeTab === 'basic' && ( @@ -403,16 +530,115 @@ const Settings = () => {
)} - {activeTab === 'permission' && ( -
-
-

角色权限由系统内置,自定义角色将在后续版本开放:

-
    -
  • 租户管理员 — 全部配置、知识库、统计、坐席管理
  • -
  • 主管 — 会话监管、客户与知识库管理、数据统计
  • -
  • 一线客服 — 工作台接待、查看客户与知识库
  • -
-
+ {activeTab === 'staff' && ( +
+ {!canViewStaff ? ( +
+ 仅管理员或主管可查看坐席列表 +
+ ) : loadingStaff ? ( +
+ ) : ( + <> +
+
+
坐席占用
+
+ {seatUsed} + / {seatLimit} +
+
+
+ 启用中的管理员、主管、客服各占 1 个坐席。禁用账号释放配额。角色:客服接待会话;主管可看统计与客户;管理员可改系统设置。 +
+
+ + {staffList.length === 0 ? ( + + ) : ( +
+ + + + + + + + + {isAdmin && ( + + )} + + + + {staffList.map(s => { + const st = statusLabel[s.status] || statusLabel.offline + return ( + + + + + + + {isAdmin && ( + + )} + + ) + })} + +
账号昵称角色状态创建时间操作
{s.username}{s.nickname || '—'} + + {roleLabel[s.role] || s.role} + + + + {st.text} + + + {s.created_at ? s.created_at.slice(0, 16) : '—'} + e.stopPropagation()}> +
+ + {s.status === 'disabled' ? ( + + ) : ( + handleDisableStaff(s)} + disabled={s.id === user?.user_id} + > + + + )} +
+
+
+ )} + + )}
)} @@ -541,6 +767,75 @@ const Settings = () => {
+ + setStaffModalOpen(false)} + onOk={() => staffForm.submit()} + confirmLoading={savingStaff} + destroyOnClose + okText="保存" + width={440} + > +
+ {!editingStaff && ( + <> + + + + + + + + )} + + + + + + + + + + + )} +
+
) } diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 6138cbd..860c412 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -223,6 +223,28 @@ export const getChannels = () => get('/channels') export const createChannel = (data: { type: string; name?: string }) => post('/channels', data) export const updateChannel = (id: number, data: { name?: string; status?: string }) => put(`/channels/${id}`, data) +// 坐席账号 +export interface StaffUser { + id: number + username: string + nickname: string + role: string + status: string + created_at: string + last_online_at?: string +} +export interface StaffListResult { + list: StaffUser[] + seat_limit: number + seat_used: number +} +export const getStaff = () => get('/staff') +export const createStaff = (data: { username: string; password: string; nickname?: string; role?: string }) => + post('/staff', data) +export const updateStaff = (id: number, data: { nickname?: string; role?: string; status?: string; password?: string }) => + put(`/staff/${id}`, data) +export const deleteStaff = (id: number) => del(`/staff/${id}`) + // Tenant settings export type WorkHours = Record export interface TenantSettings {