import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../ui/table"; import { Input } from "../ui/input"; import { Button } from "../ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../ui/dialog"; import { Label } from "../ui/label"; import { Switch } from "../ui/switch"; import { Badge } from "../ui/badge"; import { Plus, Edit, MoreHorizontal, ChevronsUpDown } from "lucide-react"; import { toast } from "sonner"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Checkbox } from "../ui/checkbox"; import { SearchableSelect } from "../ui/searchable-select"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "../ui/command"; import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "../ui/pagination"; import { getLabels, getLabel, createLabel, updateLabel, deleteLabel } from "../../services/labelService"; import type { LabelDto, LabelCreateInput, LabelUpdateInput } from "../../types/label"; import { getLocations } from "../../services/locationService"; import { getLabelCategories } from "../../services/labelCategoryService"; import { getLabelTypes } from "../../services/labelTypeService"; import { getLabelTemplates } from "../../services/labelTemplateService"; import { getProducts } from "../../services/productService"; import type { LocationDto } from "../../types/location"; import type { LabelCategoryDto } from "../../types/labelCategory"; import type { LabelTypeDto } from "../../types/labelType"; import type { LabelTemplateDto } from "../../types/labelTemplate"; import type { ProductDto } from "../../types/product"; function toDisplay(v: string | null | undefined): string { const s = (v ?? "").trim(); return s ? s : "None"; } /** 列表行:标签编码(接口可能只返回 id 为 LabelCode) */ function labelRowCode(item: LabelDto): string { const c = (item.labelCode ?? item.id ?? "").trim(); return c || "None"; } /** 列表行:产品列(优先展示名称,否则展示绑定数量) */ function labelRowProductsText(item: LabelDto): string { const pn = (item.productName ?? "").trim(); if (pn) return pn; const n = item.productIds?.length ?? 0; if (n > 0) return `${n} product(s)`; return "None"; } /** 列表行:最后编辑时间 */ function labelRowLastEdited(item: LabelDto): string { const le = (item.lastEdited ?? "").trim(); if (le) return le; const ct = item.creationTime; if (ct) { try { return new Date(ct).toLocaleString(); } catch { return String(ct); } } return "None"; } /** 详情 / 列表行 → 编辑表单(列表接口可能缺 ID 字段,需再以 GET 详情补全) */ function labelDtoToUpdateForm(d: LabelDto): LabelUpdateInput { const ids = d.productIds; return { labelName: d.labelName ?? "", templateCode: d.templateCode ?? "", locationId: d.locationId ?? "", labelCategoryId: d.labelCategoryId ?? "", labelTypeId: d.labelTypeId ?? "", productIds: Array.isArray(ids) ? [...ids] : [], labelInfoJson: d.labelInfoJson ?? null, state: d.state ?? true, }; } type ProductOptionRow = { id: string; name: string }; function templateListCode(t: LabelTemplateDto): string { return (t.templateCode ?? t.id ?? "").trim(); } function templateListLabel(t: LabelTemplateDto): string { const name = (t.templateName ?? t.name ?? "").trim() || "None"; const code = templateListCode(t) || "None"; return `${name} (${code})`; } function useLabelFormReferenceData(open: boolean) { const [loading, setLoading] = useState(false); const [templates, setTemplates] = useState([]); const [locations, setLocations] = useState([]); const [categories, setCategories] = useState([]); const [types, setTypes] = useState([]); const [products, setProducts] = useState([]); useEffect(() => { if (!open) return; let cancelled = false; (async () => { setLoading(true); try { const [tplRes, locRes, catRes, typeRes, prodRes] = await Promise.all([ getLabelTemplates({ skipCount: 0, maxResultCount: 500 }), getLocations({ skipCount: 0, maxResultCount: 500 }), getLabelCategories({ skipCount: 0, maxResultCount: 500 }), getLabelTypes({ skipCount: 0, maxResultCount: 500 }), getProducts({ skipCount: 0, maxResultCount: 500 }), ]); if (cancelled) return; setTemplates(tplRes.items ?? []); setLocations(locRes.items ?? []); setCategories(catRes.items ?? []); setTypes(typeRes.items ?? []); setProducts(prodRes.items ?? []); } catch (e: any) { if (!cancelled) { toast.error("Failed to load options", { description: e?.message ? String(e.message) : "Check network or sign-in.", }); setTemplates([]); setLocations([]); setCategories([]); setTypes([]); setProducts([]); } } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [open]); const productOptions: ProductOptionRow[] = useMemo( () => products.map((p) => { const name = (p.productName ?? p.productCode ?? "").trim() || p.id; return { id: p.id, name }; }), [products], ); return { loading, templates, locations, categories, types, productOptions }; } function ProductMultiSelectField({ value, onChange, disabled, productOptions, }: { value: string[]; onChange: (next: string[]) => void; disabled?: boolean; productOptions: ProductOptionRow[]; }) { const [open, setOpen] = useState(false); const summary = useMemo(() => { if (value.length === 0) return "Select products (multi-select)"; const names = value .map((id) => productOptions.find((p) => p.id === id)?.name ?? id) .slice(0, 2); const more = value.length > 2 ? `, ${value.length} total` : ""; return `${names.join(", ")}${more}`; }, [value, productOptions]); const toggle = useCallback( (id: string, checked: boolean) => { const set = new Set(value); if (checked) set.add(id); else set.delete(id); onChange(Array.from(set)); }, [value, onChange], ); const extraProductIds = useMemo( () => value.filter((id) => !productOptions.some((p) => p.id === id)), [value, productOptions], ); return ( No matching products. {productOptions.map((p) => ( { toggle(p.id, !value.includes(p.id)); }} className="cursor-pointer" > {p.name} {p.id} ))} {extraProductIds.length > 0 ? ( {extraProductIds.map((id) => ( { toggle(id, !value.includes(id)); }} className="cursor-pointer" > {id} ))} ) : null} ); } export function LabelsList() { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [editingLabel, setEditingLabel] = useState(null); const [deletingLabel, setDeletingLabel] = useState(null); const [labels, setLabels] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); const [refreshSeq, setRefreshSeq] = useState(0); const [actionsOpenForId, setActionsOpenForId] = useState(null); const [keyword, setKeyword] = useState(""); const [locationFilter, setLocationFilter] = useState("all"); const [labelCategoryFilter, setLabelCategoryFilter] = useState("all"); const [labelTypeFilter, setLabelTypeFilter] = useState("all"); const [templateFilter, setTemplateFilter] = useState("all"); const [stateFilter, setStateFilter] = useState("all"); const [pageIndex, setPageIndex] = useState(1); const [pageSize, setPageSize] = useState(10); const abortRef = useRef(null); const keywordTimerRef = useRef(null); const [debouncedKeyword, setDebouncedKeyword] = useState(""); useEffect(() => { if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); keywordTimerRef.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300); return () => { if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); }; }, [keyword]); const totalPages = Math.max(1, Math.ceil(total / pageSize)); useEffect(() => { setPageIndex(1); }, [debouncedKeyword, locationFilter, labelCategoryFilter, labelTypeFilter, templateFilter, stateFilter, pageSize]); useEffect(() => { const run = async () => { abortRef.current?.abort(); const ac = new AbortController(); abortRef.current = ac; setLoading(true); try { const skipCount = (pageIndex - 1) * pageSize; const res = await getLabels( { skipCount, maxResultCount: pageSize, keyword: debouncedKeyword || undefined, locationId: locationFilter !== "all" ? locationFilter : undefined, labelCategoryId: labelCategoryFilter !== "all" ? labelCategoryFilter : undefined, labelTypeId: labelTypeFilter !== "all" ? labelTypeFilter : undefined, templateCode: templateFilter !== "all" ? templateFilter : undefined, state: stateFilter === "all" ? undefined : stateFilter === "true", }, ac.signal, ); setLabels(res.items ?? []); setTotal(res.totalCount ?? 0); } catch (e: any) { if (e?.name === "AbortError") return; toast.error("Failed to load labels.", { description: e?.message ? String(e.message) : "Please try again.", }); setLabels([]); setTotal(0); } finally { setLoading(false); } }; run(); return () => abortRef.current?.abort(); }, [debouncedKeyword, locationFilter, labelCategoryFilter, labelTypeFilter, templateFilter, stateFilter, pageIndex, pageSize, refreshSeq]); const refreshList = () => setRefreshSeq((x) => x + 1); const openEdit = (label: LabelDto) => { setActionsOpenForId(null); setEditingLabel(label); setIsEditDialogOpen(true); }; const openDelete = (label: LabelDto) => { setActionsOpenForId(null); setDeletingLabel(label); setIsDeleteDialogOpen(true); }; return (
setKeyword(e.target.value)} style={{ height: 40, boxSizing: 'border-box' }} className="bg-white border border-gray-300 rounded-md w-40 shrink-0 placeholder:text-gray-500" />
Label Code Label Name Location Category Type Template Products State Last Edited Actions {loading ? ( Loading... ) : labels.length === 0 ? ( No results. ) : ( labels.map((item) => ( {labelRowCode(item)} {toDisplay(item.labelName)} {toDisplay(item.locationName ?? item.locationId)} {toDisplay(item.labelCategoryName ?? item.labelCategoryId)} {toDisplay(item.labelTypeName ?? item.labelTypeId)} {toDisplay(item.templateName ?? item.templateCode)} {labelRowProductsText(item)} {item.state === true ? "Active" : "Inactive"} {labelRowLastEdited(item)} setActionsOpenForId(open ? item.id : null)} > )) )}
Showing {total === 0 ? 0 : (pageIndex - 1) * pageSize + 1}- {Math.min(pageIndex * pageSize, total)} of {total}
{ e.preventDefault(); setPageIndex((p) => Math.max(1, p - 1)); }} aria-disabled={pageIndex <= 1} className={pageIndex <= 1 ? "pointer-events-none opacity-50" : ""} /> e.preventDefault()} > Page {pageIndex} / {totalPages} { e.preventDefault(); setPageIndex((p) => Math.min(totalPages, p + 1)); }} aria-disabled={pageIndex >= totalPages} className={pageIndex >= totalPages ? "pointer-events-none opacity-50" : ""} />
{ setPageIndex(1); refreshList(); }} /> { setIsEditDialogOpen(open); if (!open) setEditingLabel(null); }} onUpdated={refreshList} /> { setIsDeleteDialogOpen(open); if (!open) setDeletingLabel(null); }} onDeleted={refreshList} />
); } function CreateLabelDialog({ open, onOpenChange, onCreated, }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated: () => void; }) { const { loading: refLoading, templates, locations, categories, types, productOptions } = useLabelFormReferenceData(open); const [submitting, setSubmitting] = useState(false); const [form, setForm] = useState({ labelCode: "", labelName: "", templateCode: "", locationId: "", labelCategoryId: "", labelTypeId: "", productIds: [], labelInfoJson: null, state: true, }); const resetForm = () => { setForm({ labelCode: "", labelName: "", templateCode: "", locationId: "", labelCategoryId: "", labelTypeId: "", productIds: [], labelInfoJson: null, state: true, }); }; useEffect(() => { if (!open) { resetForm(); } }, [open]); const submit = async () => { if (!form.labelCode.trim() || !form.labelName.trim() || !form.templateCode.trim() || !form.locationId.trim() || !form.labelCategoryId.trim() || !form.labelTypeId.trim()) { toast.error("Validation failed", { description: "Fill all required fields and select template, location, category, and type.", }); return; } if (form.productIds.length === 0) { toast.error("Validation failed", { description: "Select at least one product.", }); return; } setSubmitting(true); try { await createLabel(form); toast.success("Label created.", { description: "The label has been created successfully.", }); onOpenChange(false); onCreated(); } catch (e: any) { toast.error("Failed to create label.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; const templateOptions = useMemo( () => templates .filter((t) => templateListCode(t)) .map((t) => ({ value: templateListCode(t), label: templateListLabel(t), })), [templates], ); const locationOptions = useMemo( () => locations.map((loc) => ({ value: loc.id, label: toDisplay(loc.locationName ?? loc.locationCode ?? loc.id), })), [locations], ); const categoryOptions = useMemo( () => categories.map((c) => ({ value: c.id, label: toDisplay(c.categoryName ?? c.categoryCode ?? c.id), })), [categories], ); const typeOptions = useMemo( () => types.map((ty) => ({ value: ty.id, label: toDisplay(ty.typeName ?? ty.typeCode ?? ty.id), })), [types], ); return ( Add New Label Enter the details for the new label.
setForm((p) => ({ ...p, labelCode: e.target.value }))} />
setForm((p) => ({ ...p, labelName: e.target.value }))} />
setForm((p) => ({ ...p, templateCode: v }))} options={templateOptions} placeholder="Select template" searchPlaceholder="Search template…" emptyText="No templates found." disabled={refLoading} />
setForm((p) => ({ ...p, locationId: v }))} options={locationOptions} placeholder="Select location" searchPlaceholder="Search location…" emptyText="No locations found." disabled={refLoading} />
setForm((p) => ({ ...p, labelCategoryId: v }))} options={categoryOptions} placeholder="Select category" searchPlaceholder="Search category…" emptyText="No categories found." disabled={refLoading} />
setForm((p) => ({ ...p, labelTypeId: v }))} options={typeOptions} placeholder="Select type" searchPlaceholder="Search type…" emptyText="No types found." disabled={refLoading} />
setForm((p) => ({ ...p, productIds: next }))} disabled={refLoading} productOptions={productOptions} />
Enabled
setForm((p) => ({ ...p, state: checked }))} />
); } function EditLabelDialog({ open, label, onOpenChange, onUpdated, }: { open: boolean; label: LabelDto | null; onOpenChange: (open: boolean) => void; onUpdated: () => void; }) { const { loading: refLoading, templates, locations, categories, types, productOptions } = useLabelFormReferenceData(open); const [submitting, setSubmitting] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [form, setForm] = useState({ labelName: "", templateCode: "", locationId: "", labelCategoryId: "", labelTypeId: "", productIds: [], labelInfoJson: null, state: true, }); useEffect(() => { if (!open || !label?.id) return; const id = label.id; setForm(labelDtoToUpdateForm(label)); const ac = new AbortController(); let cancelled = false; setDetailLoading(true); (async () => { try { const detail = await getLabel(id, ac.signal); if (cancelled) return; setForm(labelDtoToUpdateForm(detail)); } catch (e: any) { if (cancelled || e?.name === "AbortError") return; toast.error("Failed to load label details.", { description: e?.message ? String(e.message) : "Form shows list data only; check network.", }); } finally { if (!cancelled) setDetailLoading(false); } })(); return () => { cancelled = true; ac.abort(); }; }, [open, label]); const submit = async () => { if (!label?.id) return; if (!form.labelName.trim() || !form.templateCode.trim() || !form.locationId.trim() || !form.labelCategoryId.trim() || !form.labelTypeId.trim()) { toast.error("Validation failed", { description: "Fill all required fields and select template, location, category, and type.", }); return; } if (form.productIds.length === 0) { toast.error("Validation failed", { description: "Select at least one product.", }); return; } setSubmitting(true); try { await updateLabel(label.id, form); toast.success("Label updated.", { description: "The label has been updated successfully.", }); onOpenChange(false); onUpdated(); } catch (e: any) { toast.error("Failed to update label.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; const editTemplateOptions = useMemo(() => { const base = templates .filter((t) => templateListCode(t)) .map((t) => ({ value: templateListCode(t), label: templateListLabel(t), })); const c = form.templateCode; if (c && !base.some((o) => o.value === c)) { return [{ value: c, label: `${c} (current)` }, ...base]; } return base; }, [templates, form.templateCode]); const editLocationOptions = useMemo(() => { const base = locations.map((loc) => ({ value: loc.id, label: toDisplay(loc.locationName ?? loc.locationCode ?? loc.id), })); const id = form.locationId; if (id && !base.some((o) => o.value === id)) { return [{ value: id, label: `${id} (current)` }, ...base]; } return base; }, [locations, form.locationId]); const editCategoryOptions = useMemo(() => { const base = categories.map((c) => ({ value: c.id, label: toDisplay(c.categoryName ?? c.categoryCode ?? c.id), })); const id = form.labelCategoryId; if (id && !base.some((o) => o.value === id)) { return [{ value: id, label: `${id} (current)` }, ...base]; } return base; }, [categories, form.labelCategoryId]); const editTypeOptions = useMemo(() => { const base = types.map((ty) => ({ value: ty.id, label: toDisplay(ty.typeName ?? ty.typeCode ?? ty.id), })); const id = form.labelTypeId; if (id && !base.some((o) => o.value === id)) { return [{ value: id, label: `${id} (current)` }, ...base]; } return base; }, [types, form.labelTypeId]); return ( Edit Label {detailLoading ? "Loading label details…" : "Update the label details."}
setForm((p) => ({ ...p, labelName: e.target.value }))} disabled={detailLoading} />
setForm((p) => ({ ...p, templateCode: v }))} options={editTemplateOptions} placeholder="Select template" searchPlaceholder="Search template…" emptyText="No templates found." disabled={refLoading || detailLoading} />
setForm((p) => ({ ...p, locationId: v }))} options={editLocationOptions} placeholder="Select location" searchPlaceholder="Search location…" emptyText="No locations found." disabled={refLoading || detailLoading} />
setForm((p) => ({ ...p, labelCategoryId: v }))} options={editCategoryOptions} placeholder="Select category" searchPlaceholder="Search category…" emptyText="No categories found." disabled={refLoading || detailLoading} />
setForm((p) => ({ ...p, labelTypeId: v }))} options={editTypeOptions} placeholder="Select type" searchPlaceholder="Search type…" emptyText="No types found." disabled={refLoading || detailLoading} />
setForm((p) => ({ ...p, productIds: next }))} disabled={refLoading || detailLoading} productOptions={productOptions} />
Enabled
setForm((p) => ({ ...p, state: checked }))} disabled={detailLoading} />
); } function DeleteLabelDialog({ open, label, onOpenChange, onDeleted, }: { open: boolean; label: LabelDto | null; onOpenChange: (open: boolean) => void; onDeleted: () => void; }) { const [submitting, setSubmitting] = useState(false); const name = useMemo(() => { const n = (label?.labelName ?? "").trim(); return n || label?.labelCode || label?.id || "this label"; }, [label]); const submit = async () => { if (!label?.id) return; setSubmitting(true); try { await deleteLabel(label.id); toast.success("Label deleted.", { description: "The label has been removed successfully.", }); onOpenChange(false); onDeleted(); } catch (e: any) { toast.error("Failed to delete label.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; return ( Delete Label This action cannot be undone.
Are you sure you want to delete {name}?
); }