diff --git a/frontend-web/src/components/workspace/admin/position-management-page.tsx b/frontend-web/src/components/workspace/admin/position-management-page.tsx new file mode 100644 index 00000000..07ed6e18 --- /dev/null +++ b/frontend-web/src/components/workspace/admin/position-management-page.tsx @@ -0,0 +1,479 @@ +"use client"; + +import { + CheckIcon, + Loader2, + PencilIcon, + PlusIcon, + RefreshCwIcon, + Trash2Icon, + UserPlusIcon, + UsersIcon, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { resolveTagColor } from "@/components/workspace/tags/tag-colors"; +import { + POSITION_RESOURCE_HINT, + POSITION_RESOURCE_LABELS, + POSITION_RESOURCE_TYPES, + resourceTypeToTagType, + useAddMembers, + useCreatePosition, + useDeletePosition, + usePositionMembers, + usePositionTagBindings, + usePositionUsers, + usePositions, + useRemoveMembers, + useResyncPosition, + useSetTagBindings, + useUpdatePosition, + type Position, + type PositionResourceType, + type TagBindings, +} from "@/core/positions"; +import { useTags } from "@/core/tags"; +import { cn } from "@/lib/utils"; + +function errMsg(e: unknown): string { + return String(e instanceof Error ? e.message : e); +} + +/** Inline multi-select chip row for the tags of one resource type. */ +function TagMultiSelect({ + resourceType, + value, + onChange, +}: { + resourceType: PositionResourceType; + value: string[]; + onChange: (next: string[]) => void; +}) { + const { data: tags, isLoading } = useTags(resourceTypeToTagType(resourceType)); + const toggle = (id: string) => + onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]); + + if (isLoading) { + return ; + } + if (!tags || tags.length === 0) { + return ( + + 暂无{POSITION_RESOURCE_LABELS[resourceType]}标签,请先到「标签管理」创建。 + + ); + } + return ( +
+ {tags.map((tag) => { + const checked = value.includes(tag.id); + const color = resolveTagColor(tag.color); + return ( + + ); + })} +
+ ); +} + +function PositionFormDialog({ + open, + onOpenChange, + editing, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + editing: Position | null; +}) { + const createPosition = useCreatePosition(); + const updatePosition = useUpdatePosition(); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [isDefault, setIsDefault] = useState(false); + + const [syncedFor, setSyncedFor] = useState(null); + const syncKey = `${open}:${editing?.id ?? "new"}`; + if (open && syncedFor !== syncKey) { + setName(editing?.name ?? ""); + setDescription(editing?.description ?? ""); + setIsDefault(editing?.is_default ?? false); + setSyncedFor(syncKey); + } + if (!open && syncedFor !== null) setSyncedFor(null); + + const pending = createPosition.isPending || updatePosition.isPending; + const handleSave = async () => { + const trimmed = name.trim(); + if (!trimmed) return; + try { + if (editing) { + await updatePosition.mutateAsync({ id: editing.id, input: { name: trimmed, description, is_default: isDefault } }); + } else { + await createPosition.mutateAsync({ name: trimmed, description, is_default: isDefault }); + } + onOpenChange(false); + } catch (e) { + toast.error(errMsg(e)); + } + }; + + return ( + + + + {editing ? "编辑岗位" : "新增岗位"} + +
+
+ + setName(e.target.value)} placeholder="如:理赔专员" maxLength={191} /> +
+
+ +