import React, { useState, useCallback, useEffect, useRef, useMemo } 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 { Plus, Pencil, MoreHorizontal, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { skipCountForPage } from '../../lib/paginationQuery'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from '../ui/pagination'; import { getLabelTemplates, getLabelTemplate, deleteLabelTemplate } from '../../services/labelTemplateService'; import { getLocations } from '../../services/locationService'; import { getGroups } from '../../services/groupService'; import { getPartners } from '../../services/partnerService'; import { useCategoryScopeAuth } from '../../hooks/useCategoryScopeAuth'; import { filterGroupsByCompany, filterLocationsByCompanyAndRegion, } from '../../lib/labelingToolbarScope'; import { appliedLocationToEditor, type LabelTemplateDto, type LabelTemplateGetListInput } from '../../types/labelTemplate'; import type { GroupListItem } from '../../types/group'; import type { PartnerListItem } from '../../types/partner'; import { LabelTemplateEditor } from './LabelTemplateEditor'; import type { LabelElement, LabelTemplate } from '../../types/labelTemplate'; import type { LocationDto } from '../../types/location'; function toDisplay(v: string | null | undefined): string { const s = (v ?? "").trim(); return s ? s : "None"; } function locationColumnText(t: LabelTemplateDto, locations: LocationDto[]): string { const mode = appliedLocationToEditor(t); if (mode === "ALL") return "All"; const ids = t.appliedLocationIds ?? []; if (ids.length === 0) return "Specified (0)"; const names = ids.map( (id) => locations.find((l) => l.id === id)?.locationName?.trim() || id, ); if (names.length <= 2) return names.join(", "); return `${names.slice(0, 2).join(", ")} +${names.length - 2}`; } /** 列表行:名称列 ← templateName / name */ function templateListDisplayName(t: LabelTemplateDto): string { const n = (t.templateName ?? t.name ?? "").trim(); return n ? n : "None"; } /** 列表行:门店展示 ← locationText,缺省时再推导 */ function templateListDisplayLocation(t: LabelTemplateDto, locations: LocationDto[]): string { const lt = (t.locationText ?? "").trim(); if (lt) return lt; return locationColumnText(t, locations); } /** 列表行:Contents ← 接口 items / contentItems(元素名称列表) */ function templateListContentItems(t: LabelTemplateDto): string[] { if (Array.isArray(t.contentItems) && t.contentItems.length > 0) { return t.contentItems.map((x) => String(x).trim()).filter(Boolean); } if (Array.isArray(t.items) && t.items.length > 0) { return t.items.map((x) => String(x).trim()).filter(Boolean); } if (typeof t.items === "string" && t.items.trim()) { return [t.items.trim()]; } if (t.elements?.length) { return (t.elements ?? []) .map((el) => String(el.elementName ?? "").trim()) .filter(Boolean); } return []; } function templateListContentsDisplay(t: LabelTemplateDto): string { if (typeof t.contentsText === "string" && t.contentsText.trim()) { return t.contentsText.trim(); } if (typeof t.items === "string" && t.items.trim()) { return t.items.trim(); } const names = templateListContentItems(t); if (names.length === 0) return "None"; if (names.length === 1) return names[0] ?? "None"; return names.join(", "); } /** 列表行:尺寸 ← sizeText,缺省时用 width×height unit */ function resolveLabelTemplateListScopeFilters( companyFilter: string, regionFilter: string, locationFilter: string, ): Pick { const locationId = locationFilter !== 'all' ? locationFilter.trim() : undefined; if (locationId) return { locationId }; const groupId = regionFilter !== 'all' ? regionFilter.trim() : undefined; if (groupId) return { groupId }; const partnerId = companyFilter !== 'all' ? companyFilter.trim() : undefined; if (partnerId) return { partnerId }; return {}; } function templateListDisplaySize(t: LabelTemplateDto): string { const st = (t.sizeText ?? "").trim(); if (st) return st; const w = t.width; const h = t.height; const u = t.unit; if (w != null && h != null && u) return `${w}×${h} ${u}`; return "None"; } export type LabelTemplatesViewProps = { /** 进入/退出模板编辑器全屏时通知父级(用于隐藏 Labels 子 Tab 等) */ onTemplateEditorOverlayChange?: (fullscreen: boolean) => void; }; export function LabelTemplatesView({ onTemplateEditorOverlayChange }: LabelTemplatesViewProps) { const scopeAuth = useCategoryScopeAuth(); const [templates, setTemplates] = useState([]); const [viewMode, setViewMode] = useState<'list' | 'editor'>('list'); const [editingTemplateId, setEditingTemplateId] = useState(null); const [initialTemplate, setInitialTemplate] = useState(null); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); const [refreshSeq, setRefreshSeq] = useState(0); const [actionsOpenForId, setActionsOpenForId] = useState(null); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [deletingTemplate, setDeletingTemplate] = useState(null); const [keyword, setKeyword] = useState(''); const [companyFilter, setCompanyFilter] = useState('all'); const [regionFilter, setRegionFilter] = useState('all'); const [locationFilter, setLocationFilter] = useState('all'); const [stateFilter, setStateFilter] = useState('all'); const [pageIndex, setPageIndex] = useState(1); const [pageSize, setPageSize] = useState(10); const [locationCatalog, setLocationCatalog] = useState([]); const [filterGroups, setFilterGroups] = useState([]); const [filterPartners, setFilterPartners] = useState([]); 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)); const companySelectOptions = useMemo( () => [...filterPartners] .filter((p) => (p.partnerName ?? '').trim()) .sort((a, b) => (a.partnerName ?? '').localeCompare(b.partnerName ?? '', undefined, { sensitivity: 'base' })), [filterPartners], ); const regionSelectOptions = useMemo(() => { const list = filterGroupsByCompany(filterGroups, companyFilter); const m = new Map(); for (const g of list) { const id = (g.id ?? '').trim(); if (id && !m.has(id)) m.set(id, g); } return Array.from(m.values()).sort((a, b) => (a.groupName ?? '').localeCompare(b.groupName ?? '', undefined, { sensitivity: 'base' }), ); }, [filterGroups, companyFilter]); const locationsForToolbarFilter = useMemo( () => filterLocationsByCompanyAndRegion( locationCatalog, filterPartners, companyFilter, regionFilter, filterGroups, ), [locationCatalog, filterPartners, companyFilter, regionFilter, filterGroups], ); useEffect(() => { setRegionFilter('all'); setLocationFilter('all'); }, [companyFilter]); useEffect(() => { setLocationFilter('all'); }, [regionFilter]); useEffect(() => { if (companyFilter === 'all') return; if (!companySelectOptions.some((p) => p.id === companyFilter)) setCompanyFilter('all'); }, [companyFilter, companySelectOptions]); useEffect(() => { if (regionFilter === 'all') return; if (!regionSelectOptions.some((g) => g.id === regionFilter)) setRegionFilter('all'); }, [companyFilter, regionSelectOptions, regionFilter]); useEffect(() => { if (locationFilter === 'all') return; const allowed = new Set(locationsForToolbarFilter.map((l) => l.id)); if (!allowed.has(locationFilter)) setLocationFilter('all'); }, [locationsForToolbarFilter, locationFilter]); useEffect(() => { let cancelled = false; (async () => { try { const out: LocationDto[] = []; let locPage = 1; const locSize = 500; for (;;) { const res = await getLocations({ skipCount: skipCountForPage(locPage), maxResultCount: locSize, }); out.push(...(res.items ?? [])); if (!res.items || res.items.length < locSize) break; locPage += 1; if (locPage > 200) break; } const [grpRes, partnerRes] = await Promise.all([ getGroups({ skipCount: 1, maxResultCount: 500 }), getPartners({ skipCount: 1, maxResultCount: 500, state: true }), ]); if (cancelled) return; setLocationCatalog(out); setFilterGroups(grpRes.items ?? []); setFilterPartners(partnerRes.items ?? []); } catch { if (!cancelled) { setLocationCatalog([]); setFilterGroups([]); setFilterPartners([]); } } })(); return () => { cancelled = true; }; }, []); useEffect(() => { setPageIndex(1); }, [debouncedKeyword, companyFilter, regionFilter, locationFilter, stateFilter, pageSize]); useEffect(() => { if (viewMode !== 'list') return; const run = async () => { abortRef.current?.abort(); const ac = new AbortController(); abortRef.current = ac; setLoading(true); try { const skipCount = skipCountForPage(pageIndex); const kw = debouncedKeyword || undefined; const stateBool = stateFilter === 'all' ? undefined : stateFilter === 'true'; const scopeFilters = resolveLabelTemplateListScopeFilters( companyFilter, regionFilter, locationFilter, ); const res = await getLabelTemplates( { skipCount, maxResultCount: pageSize, keyword: kw, state: stateBool, ...scopeFilters, }, ac.signal, ); setTemplates(res.items ?? []); setTotal(res.totalCount ?? 0); } catch (e: any) { if (e?.name === 'AbortError') return; toast.error('Failed to load label templates.', { description: e?.message ? String(e.message) : 'Please try again.', }); setTemplates([]); setTotal(0); } finally { setLoading(false); } }; run(); return () => abortRef.current?.abort(); }, [ debouncedKeyword, companyFilter, regionFilter, locationFilter, stateFilter, pageIndex, pageSize, refreshSeq, viewMode, ]); useEffect(() => { onTemplateEditorOverlayChange?.(viewMode === "editor"); return () => { onTemplateEditorOverlayChange?.(false); }; }, [viewMode, onTemplateEditorOverlayChange]); const refreshList = () => setRefreshSeq((x) => x + 1); const handleNewTemplate = () => { setEditingTemplateId(null); setInitialTemplate(null); setViewMode('editor'); }; const handleEditTemplate = async (templateCode: string) => { setActionsOpenForId(null); setEditingTemplateId(templateCode); setLoading(true); try { const apiTemplate = await getLabelTemplate(templateCode); // 转换 API 返回的 DTO 到编辑器需要的格式 const editorTemplate: LabelTemplate = { id: apiTemplate.id, name: (apiTemplate.name ?? apiTemplate.templateName ?? '').trim() || 'Unnamed template', labelType: (apiTemplate.labelType as any) ?? 'PRICE', unit: (apiTemplate.unit as any) ?? 'cm', width: Number(apiTemplate.width ?? 6), height: Number(apiTemplate.height ?? 4), appliedLocation: appliedLocationToEditor(apiTemplate), appliedLocationIds: [...(apiTemplate.appliedLocationIds ?? apiTemplate.locationIds ?? [])], appliedPartnerType: (apiTemplate.appliedPartnerType as any) ?? undefined, appliedRegionType: (apiTemplate.appliedRegionType as any) ?? undefined, partnerIds: [...(apiTemplate.partnerIds ?? apiTemplate.companyIds ?? [])], companyIds: [...(apiTemplate.companyIds ?? apiTemplate.partnerIds ?? [])], regionIds: [ ...new Set( [...(apiTemplate.regionIds ?? []), ...(apiTemplate.groupIds ?? [])] .map((x) => String(x).trim()) .filter(Boolean), ), ], groupIds: [ ...new Set( [...(apiTemplate.regionIds ?? []), ...(apiTemplate.groupIds ?? [])] .map((x) => String(x).trim()) .filter(Boolean), ), ], showRuler: apiTemplate.showRuler ?? true, showGrid: apiTemplate.showGrid ?? true, border: apiTemplate.border ?? 'none', printOrientation: apiTemplate.printOrientation ?? 'vertical', elements: (apiTemplate.elements ?? []).map((raw, idx) => { const el = raw as LabelElement; const en = (el.elementName ?? "").trim(); return { ...el, elementName: en || `element${idx + 1}`, }; }), }; setInitialTemplate(editorTemplate); setViewMode('editor'); } catch (e: any) { toast.error('Failed to load template.', { description: e?.message ? String(e.message) : 'Please try again.', }); } finally { setLoading(false); } }; const handleCloseEditor = () => { setViewMode('list'); setEditingTemplateId(null); setInitialTemplate(null); }; const openDelete = (template: LabelTemplateDto) => { setActionsOpenForId(null); setDeletingTemplate(template); setIsDeleteDialogOpen(true); }; const filterToolbar = (
setKeyword(e.target.value)} style={{ height: 40, boxSizing: 'border-box' }} className="w-40 shrink-0 rounded-md border border-gray-300 bg-white placeholder:text-gray-500" /> {scopeAuth.requireCompanySelection ? ( ) : null}
); const tableSection = (
Label Template Location Contents Size Actions {loading ? ( Loading... ) : templates.length === 0 ? ( No templates yet. Click "New Label Template" to create one. ) : ( templates.map((t) => ( {templateListDisplayName(t)} {toDisplay(templateListDisplayLocation(t, locationCatalog))} {templateListContentsDisplay(t)} {templateListDisplaySize(t)} setActionsOpenForId(open ? t.id : null)} > )) )}
); const paginationFooter = (
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" : ""} />
); const listPanel = (
{filterToolbar} {tableSection} {paginationFooter}
); return (
{viewMode === "editor" ? (
) : (
{listPanel}
)} { setIsDeleteDialogOpen(open); if (!open) setDeletingTemplate(null); }} onDeleted={refreshList} />
); } function DeleteLabelTemplateDialog({ open, template, onOpenChange, onDeleted, }: { open: boolean; template: LabelTemplateDto | null; onOpenChange: (open: boolean) => void; onDeleted: () => void; }) { const [submitting, setSubmitting] = useState(false); const name = useMemo(() => { const n = (template?.templateName ?? template?.name ?? "").trim(); return n || (template?.templateCode ?? template?.id ?? "").trim() || "this template"; }, [template]); const submit = async () => { if (!template?.id) return; setSubmitting(true); try { await deleteLabelTemplate(template.id); toast.success("Label template deleted.", { description: "The label template has been removed successfully.", }); onOpenChange(false); onDeleted(); } catch (e: any) { toast.error("Failed to delete label template.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; return ( Delete Label Template This action cannot be undone.
Are you sure you want to delete {name}?
); }