diff --git a/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/filter-options/route.test.ts b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/filter-options/route.test.ts new file mode 100644 index 00000000..0c194219 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/filter-options/route.test.ts @@ -0,0 +1,94 @@ +export {} + +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +const mockHandleRequest = jest.fn() +const mockListColumnFilterOptions = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (data: unknown, status = 200, requestId?: string) => + new Response(JSON.stringify({ success: true, data, meta: { requestId } }), { + status, + headers: { 'content-type': 'application/json' }, + }), + jsonError: (code: string, message?: string, status = 400) => + new Response(JSON.stringify({ success: false, error: { code, message } }), { + status, + headers: { 'content-type': 'application/json' }, + }), +})) + +jest.mock('@/server/services/finished-product-progress.service', () => ({ + finishedProductProgressService: { + listColumnFilterOptions: (...args: unknown[]) => mockListColumnFilterOptions(...args), + }, +})) + +const { POST } = require('./route') + +function post(body: unknown) { + return POST({ + json: async () => body, + headers: { get: () => String(JSON.stringify(body).length) }, + } as any) +} + +describe('POST /api/supply-chain/finished-product-progress/filter-options', () => { + beforeEach(() => { + jest.clearAllMocks() + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ requestId: 'req-filter-options', user: { id: 'user-1', role: 'USER', status: 'ACTIVE' } }), + ) + mockListColumnFilterOptions.mockResolvedValue({ values: ['供应商甲', '供应商乙', ''] }) + }) + + it('按字段与当前筛选条件调用 service 计算候选值', async () => { + const response = await post({ + field: 'followUpOwner', + search: '订单', + supplier: ['供应商甲'], + brand: [], + currentNode: [], + dueDateFrom: '2026-09-01', + dueDateTo: '', + columnFilters: { brand: { mode: 'include', values: ['品牌A'] } }, + }) + const payload = await response.json() + + expect(mockListColumnFilterOptions).toHaveBeenCalledWith({ + field: 'followUpOwner', + filters: { + search: '订单', + supplier: ['供应商甲'], + brand: [], + currentNode: [], + dueDateFrom: '2026-09-01', + dueDateTo: '', + columnFilters: { brand: { mode: 'include', values: ['品牌A'] } }, + }, + user: expect.objectContaining({ id: 'user-1' }), + }) + expect(payload).toEqual(expect.objectContaining({ + success: true, + data: { values: ['供应商甲', '供应商乙', ''] }, + })) + }) + + it('非法字段或超大筛选返回 400', async () => { + const response = await post({ field: 'imageUrl' }) + expect(response.status).toBe(400) + + // 超过 200KB 体积上限的筛选选择 + const huge = { field: 'brand', columnFilters: { brand: { mode: 'include', values: Array.from({ length: 5000 }, (_, index) => `值${index}x`.padEnd(40, 'x')) } } } + const tooLarge = await post(huge) + expect(tooLarge.status).toBe(400) + }) + + it('要求登录后访问', async () => { + const request = { json: async () => ({ field: 'brand' }), headers: { get: () => '10' } } as any + await POST(request) + + expect(mockHandleRequest).toHaveBeenCalledWith(request, expect.any(Function), { requireAuth: true }) + }) +}) diff --git a/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/filter-options/route.ts b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/filter-options/route.ts new file mode 100644 index 00000000..0ea58f1f --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/filter-options/route.ts @@ -0,0 +1,42 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { + FINISHED_PRODUCT_COLUMN_FILTER_FIELDS, + finishedProductProgressColumnFiltersSchema, + normalizeFinishedProductProgressColumnFilters, +} from '@/lib/finished-product-progress' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { finishedProductProgressService } from '@/server/services/finished-product-progress.service' + +const MAX_FILTER_BYTES = 200_000 + +const bodySchema = z.object({ + field: z.enum(FINISHED_PRODUCT_COLUMN_FILTER_FIELDS), + search: z.string().trim().max(200).optional().default(''), + supplier: z.array(z.string().trim().min(1).max(200)).max(100).optional().default([]), + brand: z.array(z.string().trim().min(1).max(200)).max(100).optional().default([]), + currentNode: z.array(z.string().trim().min(1).max(200)).max(20).optional().default([]), + dueDateFrom: z.string().trim().max(20).optional().default(''), + dueDateTo: z.string().trim().max(20).optional().default(''), + columnFilters: finishedProductProgressColumnFiltersSchema.optional().default({}), +}).strict() + +// POST:列头筛选候选值(带着当前全部筛选条件计算,排除该列自身;JSON body 以支持较大的列筛选选择)。 +export async function POST(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const body = await request.json().catch(() => null) + if (!body || Buffer.byteLength(JSON.stringify(body), 'utf8') > MAX_FILTER_BYTES) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件过多,请缩小选择范围', 400, ctx.requestId) + } + const parsed = bodySchema.safeParse(body) + if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选参数不正确', 400, ctx.requestId) + const { field, ...filters } = parsed.data + const data = await finishedProductProgressService.listColumnFilterOptions({ + field, + filters: { ...filters, columnFilters: normalizeFinishedProductProgressColumnFilters(filters.columnFilters) }, + user: ctx.user, + }) + return jsonSuccess(data, 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/route.ts b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/route.ts index 4ee12769..a0fe01b2 100644 --- a/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server' import { z } from 'zod' import { ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { parseFinishedProductProgressColumnFilters } from '@/lib/finished-product-progress' import { finishedProductProgressService } from '@/server/services/finished-product-progress.service' const listSchema = z.object({ @@ -13,6 +14,7 @@ const listSchema = z.object({ currentNode: z.array(z.string().trim().min(1).max(200)).max(20).default([]), dueDateFrom: z.string().trim().max(20).default(''), dueDateTo: z.string().trim().max(20).default(''), + columnFilters: z.string().max(200_000).optional(), }) export async function GET(request: NextRequest) { @@ -24,8 +26,13 @@ export async function GET(request: NextRequest) { currentNode: request.nextUrl.searchParams.getAll('currentNode'), }) if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '成品进度筛选条件不正确', 400, ctx.requestId) - const { page, pageSize, ...filters } = parsed.data - return jsonSuccess(await finishedProductProgressService.listPage({ page, pageSize, filters, user: ctx.user }), 200, ctx.requestId) + const { page, pageSize, columnFilters, ...filters } = parsed.data + return jsonSuccess(await finishedProductProgressService.listPage({ + page, + pageSize, + filters: { ...filters, columnFilters: parseFinishedProductProgressColumnFilters(columnFilters) }, + user: ctx.user, + }), 200, ctx.requestId) }, { requireAuth: true }) } diff --git a/furniture-3d-system/src/app/intake/sample-progress/SampleProgressClient.tsx b/furniture-3d-system/src/app/intake/sample-progress/SampleProgressClient.tsx index 4f5e4428..64649b7b 100644 --- a/furniture-3d-system/src/app/intake/sample-progress/SampleProgressClient.tsx +++ b/furniture-3d-system/src/app/intake/sample-progress/SampleProgressClient.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import Image from 'next/image' import { AlertTriangle, ArrowLeft, ArrowRight, CalendarClock, Link2, Plus, Search, X } from 'lucide-react' import { @@ -11,12 +11,15 @@ import { SAMPLE_PROGRESS_STATUS_LABELS, SAMPLE_PROGRESS_STATUSES, } from '@/lib/sample-progress' +import { matchesColumnHeaderFilterSelection, toColumnFilterValue, type ColumnHeaderFilterSelection } from '@/lib/column-header-filters' import type { SampleProgressDTO } from '@/server/services/sample-progress.service' +import { ColumnHeaderFilter } from '@/components/common/ColumnHeaderFilter' import SampleProgressColumnSettings from './components/SampleProgressColumnSettings' import SampleProgressEditorDrawer from './components/SampleProgressEditorDrawer' import SampleProgressTableRow from './components/SampleProgressTableRow' import { isSampleProgressSupplierOverdue, sampleProgressSuppliers } from './sampleProgressSuppliers' import { useSampleProgressColumnVisibility } from './useSampleProgressColumnVisibility' +import type { SampleProgressColumnKey } from './sampleProgressColumns' const PAGE_SIZE = 20 const utcToday = () => { @@ -26,11 +29,27 @@ const utcToday = () => { const isOverdue = (item: SampleProgressDTO) => sampleProgressSuppliers(item) .some((supplier) => isSampleProgressSupplierOverdue(supplier, utcToday())) +// 列头筛选的行取值:供应商/快递单号/进度说明是供应商子行多值列(任一子行命中即算命中)。 +export const SAMPLE_PROGRESS_COLUMN_FILTER_EXTRACTORS: Partial string[]>> = { + openingCode: (item) => [toColumnFilterValue(item.openingCode)], + brand: (item) => [toColumnFilterValue(item.brand)], + designer: (item) => [toColumnFilterValue(item.designer)], + purchaseOwner: (item) => [toColumnFilterValue(item.purchaseOwner)], + merchandiserOwner: (item) => [toColumnFilterValue(item.merchandiserOwner)], + status: (item) => [SAMPLE_PROGRESS_STATUS_LABELS[normalizeSampleProgressStatus(item.status)] ?? ''], + productSpec: (item) => [toColumnFilterValue(item.productSize)], + material: (item) => [toColumnFilterValue(item.material)], + supplier: (item) => sampleProgressSuppliers(item).map((supplier) => toColumnFilterValue(supplier.supplier)), + courierTrackingNumber: (item) => sampleProgressSuppliers(item).map((supplier) => toColumnFilterValue(supplier.courierTrackingNumber)), + progressDescription: (item) => sampleProgressSuppliers(item).map((supplier) => toColumnFilterValue(supplier.progressDescription)), +} + export default function SampleProgressClient({ initialItems, currentUserName, currentUserRole, currentSampleStageOpeningCodes = [], embedded = false, permissionScope, permissionCanOperate = true, permissionCanEditAllRecords = false }: { initialItems: SampleProgressDTO[]; currentUserName: string; currentUserRole?: string; currentSampleStageOpeningCodes?: string[]; embedded?: boolean; permissionScope?: 'supply-chain'; permissionCanOperate?: boolean; permissionCanEditAllRecords?: boolean }) { const [items, setItems] = useState(initialItems) const [keyword, setKeyword] = useState('') const [selectedStatuses, setSelectedStatuses] = useState([]) const [overdueOnly, setOverdueOnly] = useState(false) + const [columnFilters, setColumnFilters] = useState>>({}) const [page, setPage] = useState(1) const [pageInput, setPageInput] = useState('1') const [editorItem, setEditorItem] = useState(null) @@ -43,8 +62,7 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu const canDelete = permissionCanOperate && isSampleProgressAdministrator(currentUserRole) const currentStageCodes = useMemo(() => new Set(currentSampleStageOpeningCodes.map((code) => code.trim().toUpperCase())), [currentSampleStageOpeningCodes]) - const currentStageItems = useMemo(() => items.filter((item) => currentStageCodes.has(item.openingCode.trim().toUpperCase()) && !isSampleProgressTerminalStatus(item.status)), [currentStageCodes, items]) - const filtered = useMemo(() => items.filter((item) => { + const baseFiltered = useMemo(() => items.filter((item) => { const supplierKeywords = sampleProgressSuppliers(item).flatMap((supplier) => [supplier.supplier, supplier.courierTrackingNumber, supplier.progressDescription]) const haystack = [item.openingCode, item.brand, item.designer, item.purchaseOwner, item.color, item.material, ...supplierKeywords].join(' ').toLowerCase() const normalizedStatus = normalizeSampleProgressStatus(item.status) @@ -55,6 +73,29 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu : currentStageCodes.has(item.openingCode.trim().toUpperCase()) && !isSampleProgressTerminalStatus(normalizedStatus) return (!keyword || haystack.includes(keyword.toLowerCase())) && statusMatches && (!overdueOnly || isOverdue(item)) }), [currentStageCodes, items, keyword, overdueOnly, selectedStatuses]) + const currentStageItems = useMemo(() => items.filter((item) => currentStageCodes.has(item.openingCode.trim().toUpperCase()) && !isSampleProgressTerminalStatus(item.status)), [currentStageCodes, items]) + const filtered = useMemo(() => baseFiltered.filter((item) => Object.entries(columnFilters).every(([key, selection]) => + !selection || matchesColumnHeaderFilterSelection(selection, SAMPLE_PROGRESS_COLUMN_FILTER_EXTRACTORS[key as SampleProgressColumnKey]?.(item) ?? []), + )), [baseFiltered, columnFilters]) + // 列头筛选候选值:当前其余筛选条件下(不含该列自身)的去重值,空值以空字符串展示为「(空值)」。 + const loadColumnFilterCandidates = useCallback((key: SampleProgressColumnKey) => { + const extract = SAMPLE_PROGRESS_COLUMN_FILTER_EXTRACTORS[key] + if (!extract) return Promise.resolve([]) + const values = baseFiltered + .filter((item) => Object.entries(columnFilters).every(([filterKey, selection]) => + !selection || filterKey === key || matchesColumnHeaderFilterSelection(selection, SAMPLE_PROGRESS_COLUMN_FILTER_EXTRACTORS[filterKey as SampleProgressColumnKey]?.(item) ?? []))) + .flatMap((item) => extract(item)) + return Promise.resolve([...new Set(values)].sort((left, right) => left.localeCompare(right, 'zh-CN'))) + }, [baseFiltered, columnFilters]) + const applyColumnFilter = useCallback((key: SampleProgressColumnKey, selection: ColumnHeaderFilterSelection | null) => { + setColumnFilters((current) => { + const next = { ...current } + if (selection) next[key] = selection + else delete next[key] + return next + }) + setPage(1) + }, []) const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) const rows = filtered.slice((Math.min(page, totalPages) - 1) * PAGE_SIZE, Math.min(page, totalPages) * PAGE_SIZE) const overdueCount = currentStageItems.filter(isOverdue).length @@ -95,6 +136,7 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu setKeyword('') setSelectedStatuses([]) setOverdueOnly(false) + setColumnFilters({}) setPage(1) } @@ -160,7 +202,10 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
- {columnVisibility.visibleColumns.map((column) => )} + {columnVisibility.visibleColumns.map((column) => { + const extractor = SAMPLE_PROGRESS_COLUMN_FILTER_EXTRACTORS[column.key] + return + })} {rows.map((item) => setEditorItem(item)} onView={setViewItem} onHistory={setHistoryItem} onDelete={(row) => void remove(row)} onPreview={setPreviewImage}/>) } diff --git a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/FinishedProductProgressClient.tsx b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/FinishedProductProgressClient.tsx index 7f6bd819..be75ea47 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/FinishedProductProgressClient.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/FinishedProductProgressClient.tsx @@ -5,11 +5,14 @@ import { useCallback, useRef, useState } from 'react' import { FileDown, Layers3, Plus, RotateCcw, Search, Trash2, Upload, X } from 'lucide-react' import { EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS, + serializeFinishedProductProgressColumnFilters, type FinishedProductProgressChangeLogEntry, + type FinishedProductProgressColumnFilterField, type FinishedProductProgressDetailRow, type FinishedProductProgressFilters, type FinishedProductProgressPage, } from '@/lib/finished-product-progress' +import type { ColumnHeaderFilterSelection } from '@/lib/column-header-filters' import { FinishedProductColumnSettings } from './components/FinishedProductColumnSettings' import { FinishedProductEditorDrawer } from './components/FinishedProductEditorDrawer' import { FinishedProductExportProgress } from './components/FinishedProductExportProgress' @@ -64,10 +67,13 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca setLoading(true) try { const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }) - Object.entries(nextFilters).forEach(([key, value]) => { + const { columnFilters: columnFiltersValue, ...restFilters } = nextFilters + Object.entries(restFilters).forEach(([key, value]) => { if (Array.isArray(value)) value.forEach((entry) => params.append(key, entry)) else if (value) params.set(key, value) }) + const serializedColumnFilters = serializeFinishedProductProgressColumnFilters(columnFiltersValue) + if (serializedColumnFilters) params.set('columnFilters', serializedColumnFilters) const next = await readResponse(await fetch(`/api/supply-chain/finished-product-progress?${params}`, { cache: 'no-store' })) as FinishedProductProgressPage setData(next) setFilters(nextFilters) @@ -79,6 +85,33 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca const afterImport = useCallback(() => load(filters, 1, data.pageInfo.pageSize), [data.pageInfo.pageSize, filters, load]) const importTask = useFinishedProductImport(afterImport) + // 列头筛选:候选值按当前已生效的全部筛选条件(不含该列自身)在服务端去重计算。 + const loadColumnFilterValues = useCallback(async (field: FinishedProductProgressColumnFilterField) => { + const applied = appliedFiltersRef.current + const response = await fetch('/api/supply-chain/finished-product-progress/filter-options', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + field, + search: applied.search, + supplier: applied.supplier, + brand: applied.brand, + currentNode: applied.currentNode, + dueDateFrom: applied.dueDateFrom, + dueDateTo: applied.dueDateTo, + columnFilters: applied.columnFilters, + }), + }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '候选值加载失败') + return Array.isArray(payload.data?.values) ? payload.data.values as string[] : [] + }, []) + const applyColumnFilter = useCallback((field: FinishedProductProgressColumnFilterField, selection: ColumnHeaderFilterSelection | null) => { + const nextColumnFilters = { ...appliedFiltersRef.current.columnFilters } + if (selection) nextColumnFilters[field] = selection + else delete nextColumnFilters[field] + void load({ ...appliedFiltersRef.current, columnFilters: nextColumnFilters }, 1, data.pageInfo.pageSize) + }, [data.pageInfo.pageSize, load]) const reset = () => { const next = { ...EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS } setFilters(next) @@ -160,7 +193,7 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca
- setSelectedIds((current) => { + setSelectedIds((current) => { const next = new Set(current) if (next.has(id)) next.delete(id) else next.add(id) diff --git a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductTable.tsx b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductTable.tsx index 2c058201..8451426e 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductTable.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductTable.tsx @@ -2,16 +2,21 @@ import Image from 'next/image' import { ExternalLink, Eye, History, ListChecks, Pencil, Trash2 } from 'lucide-react' -import type { FinishedProductProgressDetailRow, FinishedProductProgressRow, FinishedProductProgressSummaryRow } from '@/lib/finished-product-progress' +import { FINISHED_PRODUCT_COLUMN_FILTER_FIELDS, type FinishedProductProgressColumnFilterField, type FinishedProductProgressColumnFilters, type FinishedProductProgressDetailRow, type FinishedProductProgressRow, type FinishedProductProgressSummaryRow } from '@/lib/finished-product-progress' +import type { ColumnHeaderFilterSelection } from '@/lib/column-header-filters' +import { ColumnHeaderFilter } from '@/components/common/ColumnHeaderFilter' import type { FinishedProductProgressColumn, FinishedProductProgressColumnKey } from '../finishedProductProgressColumns' -export function FinishedProductTable({ rows, columns, canEdit, selectedIds, allSelected, onToggleSelect, onToggleGroup, onToggleAll, onEdit, onView, onHistory, onPreview, onDelete }: { rows: FinishedProductProgressSummaryRow[]; columns: readonly FinishedProductProgressColumn[]; canEdit: boolean; selectedIds: ReadonlySet; allSelected: boolean; onToggleSelect: (id: string) => void; onToggleGroup?: (ids: string[]) => void; onToggleAll: () => void; onEdit: (row: FinishedProductProgressRow) => void; onView: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void; onDelete: (row: FinishedProductProgressRow) => void }) { +export function FinishedProductTable({ rows, columns, canEdit, selectedIds, allSelected, columnFilters = {}, onLoadColumnFilterValues = () => Promise.resolve([]), onApplyColumnFilter = () => {}, onToggleSelect, onToggleGroup, onToggleAll, onEdit, onView, onHistory, onPreview, onDelete }: { rows: FinishedProductProgressSummaryRow[]; columns: readonly FinishedProductProgressColumn[]; canEdit: boolean; selectedIds: ReadonlySet; allSelected: boolean; columnFilters?: FinishedProductProgressColumnFilters; onLoadColumnFilterValues?: (field: FinishedProductProgressColumnFilterField) => Promise; onApplyColumnFilter?: (field: FinishedProductProgressColumnFilterField, selection: ColumnHeaderFilterSelection | null) => void; onToggleSelect: (id: string) => void; onToggleGroup?: (ids: string[]) => void; onToggleAll: () => void; onEdit: (row: FinishedProductProgressRow) => void; onView: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void; onDelete: (row: FinishedProductProgressRow) => void }) { const minWidth = columns.reduce((total, column) => total + column.minWidth, 0) const detailCount = rows.reduce((total, group) => total + group.details.length, 0) const tableRows = buildFinishedProductTableRows(rows) + const filterableFields = new Set(FINISHED_PRODUCT_COLUMN_FILTER_FIELDS) + const applyColumnFilter = onApplyColumnFilter ?? (() => {}) + const loadColumnFilterValues = onLoadColumnFilterValues ?? (() => Promise.resolve([])) return
{column.label}
{extractor ? {column.label} loadColumnFilterCandidates(column.key)} selection={columnFilters[column.key] ?? null} onApply={(selection) => applyColumnFilter(column.key, selection)} /> : column.label}
- {canEdit && }{columns.map((column) => )} + {canEdit && }{columns.map((column) => )}{tableRows.map(({ row, isContractFirst, contractRowSpan, contractRowIds, contractBandClass }) => {canEdit && } {columns.map((column) => column.key === 'orderSequenceNo' diff --git a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/useFinishedProductExport.ts b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/useFinishedProductExport.ts index cc32d769..78af48fa 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/useFinishedProductExport.ts +++ b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/useFinishedProductExport.ts @@ -1,7 +1,7 @@ 'use client' import { useCallback, useRef, useState } from 'react' -import type { FinishedProductProgressDetailRow, FinishedProductProgressFilters, FinishedProductProgressPage } from '@/lib/finished-product-progress' +import { serializeFinishedProductProgressColumnFilters, type FinishedProductProgressDetailRow, type FinishedProductProgressFilters, type FinishedProductProgressPage } from '@/lib/finished-product-progress' const PAGE_SIZE = 200 @@ -42,10 +42,13 @@ export function useFinishedProductExport() { let total = Number.POSITIVE_INFINITY do { const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) }) - Object.entries(filters).forEach(([key, value]) => { + const { columnFilters: columnFiltersValue, ...restFilters } = filters + Object.entries(restFilters).forEach(([key, value]) => { if (Array.isArray(value)) value.forEach((entry) => params.append(key, entry)) else if (value) params.set(key, value) }) + const serializedColumnFilters = serializeFinishedProductProgressColumnFilters(columnFiltersValue) + if (serializedColumnFilters) params.set('columnFilters', serializedColumnFilters) const response = await fetch(`/api/supply-chain/finished-product-progress?${params.toString()}`, { cache: 'no-store' }) const payload = await response.json().catch(() => null) if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '导出数据拉取失败') diff --git a/furniture-3d-system/src/components/common/ColumnHeaderFilter.module.css b/furniture-3d-system/src/components/common/ColumnHeaderFilter.module.css new file mode 100644 index 00000000..e9aab8e2 --- /dev/null +++ b/furniture-3d-system/src/components/common/ColumnHeaderFilter.module.css @@ -0,0 +1,195 @@ +.columnFilterTrigger { + display: inline-flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: #71717a; + cursor: pointer; + vertical-align: middle; +} + +.columnFilterTrigger:hover, +.columnFilterActive { + background: #eff6ff; + color: #2563eb; +} + +.columnFilterPopover { + position: fixed; + z-index: 125; + display: flex; + width: min(300px, calc(100vw - 16px)); + flex-direction: column; + gap: 8px; + margin: 0; + padding: 10px; + border: 1px solid #d4d4d8; + border-radius: 6px; + background: #fff; + box-shadow: 0 10px 24px rgba(24, 24, 27, .12); + color: #27272a; + font-size: 12px; + letter-spacing: 0; + text-align: left; +} + +.columnFilterHeading, +.columnFilterSelection, +.columnFilterActions { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.columnFilterHeading button { + display: flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 4px; + background: transparent; + color: #71717a; + cursor: pointer; +} + +.columnFilterSearch { + display: flex; + height: 32px; + flex: 0 0 auto; + align-items: center; + gap: 6px; + padding: 0 8px; + border: 1px solid #d4d4d8; + border-radius: 4px; + color: #71717a; +} + +.columnFilterSearch:focus-within { + border-color: #71717a; +} + +.columnFilterSearch input { + width: 100%; + min-width: 0; + border: 0; + outline: none; + background: transparent; + color: #27272a; + font: inherit; +} + +.columnFilterSelection label, +.columnFilterOption { + display: flex; + align-items: center; + gap: 7px; + cursor: pointer; +} + +.columnFilterPopover input[type='checkbox'] { + width: 14px; + height: 14px; + /* The whole label is clickable; keep the mark independent of global touch-target minimums. */ + min-width: 14px; + min-height: 14px; + max-width: 14px; + max-height: 14px; + flex: 0 0 auto; + margin: 0; + accent-color: #27272a; +} + +.columnFilterSelection button { + padding: 0; + border: 0; + background: transparent; + color: #2563eb; + font: inherit; + cursor: pointer; +} + +.columnFilterCount { + color: #71717a; + font-size: 11px; +} + +.columnFilterError { + color: #b91c1c; + font-size: 12px; +} + +.columnFilterOptions { + height: 224px; + min-height: 32px; + flex: 1 1 auto; + overflow-y: auto; + overscroll-behavior: contain; +} + +.columnFilterOption { + height: 32px; + padding: 0 5px; + border-radius: 4px; +} + +.columnFilterOption:hover, +.columnFilterOption:focus-within { + background: #f4f4f5; +} + +.columnFilterOption span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.columnFilterMessage { + display: flex; + min-height: 96px; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px; + color: #71717a; +} + +.columnFilterActions { + padding-top: 8px; + border-top: 1px solid #e4e4e7; +} + +.columnFilterActions button { + display: inline-flex; + height: 30px; + align-items: center; + justify-content: center; + gap: 5px; + padding: 0 10px; + border: 1px solid #d4d4d8; + border-radius: 4px; + background: #fff; + color: #52525b; + font: inherit; + cursor: pointer; +} + +.columnFilterActions .columnFilterApply { + border-color: #27272a; + background: #27272a; + color: #fff; +} + +.columnFilterPopover button:disabled { + cursor: not-allowed; + opacity: .5; +} diff --git a/furniture-3d-system/src/components/common/ColumnHeaderFilter.test.tsx b/furniture-3d-system/src/components/common/ColumnHeaderFilter.test.tsx new file mode 100644 index 00000000..633c33ba --- /dev/null +++ b/furniture-3d-system/src/components/common/ColumnHeaderFilter.test.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { ColumnHeaderFilter } from './ColumnHeaderFilter' + +const loadValues = jest.fn() + +const baseProps = { + label: '供应商', + active: false, + loadValues, + selection: null, + onApply: jest.fn(), +} + +describe('ColumnHeaderFilter', () => { + beforeEach(() => { + jest.clearAllMocks() + loadValues.mockResolvedValue(['供应商甲', '供应商乙', '']) + }) + + async function open() { + fireEvent.click(screen.getByRole('button', { name: '筛选供应商' })) + await screen.findByRole('checkbox', { name: '供应商甲' }) + return screen.getByRole('dialog', { name: '筛选供应商' }) + } + + it('弹窗展示候选值,空值显示为(空值)且可勾选', async () => { + render() + const dialog = await open() + expect(within(dialog).getByRole('checkbox', { name: '(空值)' })).toBeInTheDocument() + expect(within(dialog).getByText('已选 3 / 3 项')).toBeInTheDocument() + }) + + it('先全不选再勾选候选值,应用回传 include 选择', async () => { + const onApply = jest.fn() + render() + const dialog = await open() + fireEvent.click(within(dialog).getByRole('button', { name: '全不选' })) + fireEvent.click(within(dialog).getByRole('checkbox', { name: '供应商甲' })) + fireEvent.click(within(dialog).getByRole('button', { name: '应用' })) + expect(onApply).toHaveBeenCalledWith({ mode: 'include', values: ['供应商甲'] }) + }) + + it('exclude 模式下未勾选任何值时应用等于清除(回传 null)', async () => { + const onApply = jest.fn() + render() + const dialog = await open() + fireEvent.click(within(dialog).getByRole('button', { name: '应用' })) + expect(onApply).toHaveBeenCalledWith(null) + }) + + it('默认 exclude 语义:直接排除所选值', async () => { + const onApply = jest.fn() + render() + const dialog = await open() + fireEvent.click(within(dialog).getByRole('checkbox', { name: '供应商乙' })) + fireEvent.click(within(dialog).getByRole('button', { name: '应用' })) + expect(onApply).toHaveBeenCalledWith({ mode: 'exclude', values: ['供应商乙'] }) + }) + + it('清除筛选按钮直接回传 null', async () => { + const onApply = jest.fn() + render() + const dialog = await open() + fireEvent.click(within(dialog).getByRole('button', { name: '清除筛选' })) + expect(onApply).toHaveBeenCalledWith(null) + }) + + it('搜索框过滤候选值并支持全选搜索结果', async () => { + const onApply = jest.fn() + render() + const dialog = await open() + fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: '供应商甲' } }) + expect(within(dialog).getByRole('checkbox', { name: '供应商甲' })).toBeInTheDocument() + expect(within(dialog).queryByRole('checkbox', { name: '供应商乙' })).not.toBeInTheDocument() + fireEvent.click(within(dialog).getByRole('checkbox', { name: '全选搜索结果' })) + fireEvent.click(within(dialog).getByRole('button', { name: '应用' })) + expect(onApply).toHaveBeenCalledWith({ mode: 'include', values: ['供应商甲'] }) + }) + + it('候选值加载失败时展示错误与重试', async () => { + loadValues.mockRejectedValueOnce(new Error('候选值加载失败')) + render() + fireEvent.click(screen.getByRole('button', { name: '筛选供应商' })) + expect(await screen.findByRole('alert')).toHaveTextContent('候选值加载失败') + loadValues.mockResolvedValueOnce(['供应商甲']) + fireEvent.click(screen.getByRole('button', { name: '重试' })) + expect(await screen.findByRole('checkbox', { name: '供应商甲' })).toBeInTheDocument() + }) +}) diff --git a/furniture-3d-system/src/components/common/ColumnHeaderFilter.tsx b/furniture-3d-system/src/components/common/ColumnHeaderFilter.tsx new file mode 100644 index 00000000..02dd416a --- /dev/null +++ b/furniture-3d-system/src/components/common/ColumnHeaderFilter.tsx @@ -0,0 +1,239 @@ +'use client' + +import { Check, Filter, LoaderCircle, Search, X } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import type { ColumnHeaderFilterSelection } from '@/lib/column-header-filters' +import styles from './ColumnHeaderFilter.module.css' + +type DraftSelection = { mode: 'include' | 'exclude'; values: string[] } +const ROW_HEIGHT = 32 +const WINDOW_SIZE = 18 + +/** + * 通用列头搜索筛选(交互与 1688代发数据明细 的 DropshipHeaderFilter 一致): + * 漏斗图标 → 弹窗(搜索 + 全选/全不选 + 候选值复选列表 + 已选计数 + 应用/清除筛选)。 + * 候选值由调用方提供(loadValues),应用结果回传给调用方;exclude+空值 = 清除筛选。 + */ +export function ColumnHeaderFilter({ + label, + active, + disabled = false, + loadValues, + selection, + onApply, +}: { + label: string + active: boolean + disabled?: boolean + loadValues: () => Promise + selection: ColumnHeaderFilterSelection | null + onApply: (selection: ColumnHeaderFilterSelection | null) => void +}) { + const [open, setOpen] = useState(false) + const [draft, setDraft] = useState({ mode: 'exclude', values: [] }) + const [search, setSearch] = useState('') + const [values, setValues] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [retry, setRetry] = useState(0) + const [scrollTop, setScrollTop] = useState(0) + const [position, setPosition] = useState({ left: 0, top: 0, maxHeight: 410 }) + const triggerRef = useRef(null) + const popupRef = useRef(null) + const searchRef = useRef(null) + const allRef = useRef(null) + const listRef = useRef(null) + const draftSet = useMemo(() => new Set(draft.values), [draft.values]) + const matches = useMemo(() => { + const query = search.trim().toLocaleLowerCase() + return values.filter((value) => (value || '(空值)').toLocaleLowerCase().includes(query)) + }, [search, values]) + const isChecked = (value: string) => draft.mode === 'exclude' ? !draftSet.has(value) : draftSet.has(value) + const matchingChecked = matches.reduce((count, value) => count + Number(isChecked(value)), 0) + const selectedCount = values.reduce((count, value) => count + Number(isChecked(value)), 0) + const allChecked = matches.length > 0 && matchingChecked === matches.length + const windowStart = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - 3) + + useEffect(() => { + if (allRef.current) allRef.current.indeterminate = matchingChecked > 0 && !allChecked + }, [allChecked, matchingChecked, open, loading]) + + useEffect(() => { + if (!open) return + const controller = new AbortController() + async function load() { + setLoading(true) + setError('') + try { + const candidates = await loadValues() + if (!controller.signal.aborted) { + setValues(Array.isArray(candidates) ? candidates : []) + setLoading(false) + } + } catch (reason) { + if (!controller.signal.aborted) { + setError(reason instanceof Error ? reason.message : '候选值加载失败') + setLoading(false) + } + } + } + void load() + return () => controller.abort() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, retry]) + + useEffect(() => { + if (!open) return + const place = () => { + const bounds = triggerRef.current?.getBoundingClientRect() + if (!bounds) return + const width = Math.min(300, window.innerWidth - 16) + const below = window.innerHeight - bounds.bottom - 12 + const above = bounds.top - 12 + const maxHeight = Math.min(410, Math.max(below, above)) + setPosition({ + left: Math.max(8, Math.min(bounds.right - width, window.innerWidth - width - 8)), + top: below >= Math.min(410, above) ? bounds.bottom + 6 : Math.max(8, bounds.top - maxHeight - 6), + maxHeight, + }) + } + const outside = (event: PointerEvent) => { + if (!popupRef.current?.contains(event.target as Node) && !triggerRef.current?.contains(event.target as Node)) { + setOpen(false) + } + } + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + setOpen(false) + triggerRef.current?.focus() + } + } + place() + searchRef.current?.focus() + window.addEventListener('resize', place) + window.addEventListener('scroll', place, true) + document.addEventListener('pointerdown', outside) + document.addEventListener('keydown', escape) + return () => { + window.removeEventListener('resize', place) + window.removeEventListener('scroll', place, true) + document.removeEventListener('pointerdown', outside) + document.removeEventListener('keydown', escape) + } + }, [open]) + + function toggle(value: string) { + setDraft((current) => ({ + ...current, + values: current.values.includes(value) + ? current.values.filter((item) => item !== value) : [...current.values, value], + })) + } + + function toggleAll() { + if (!search.trim()) { + setDraft({ mode: allChecked ? 'include' : 'exclude', values: [] }) + return + } + const next = new Set(draft.values) + for (const value of matches) { + if ((draft.mode === 'include') === !allChecked) next.add(value) + else next.delete(value) + } + setDraft({ ...draft, values: [...next] }) + } + + function apply() { + // exclude 且未勾选任何值 = 清除筛选;include 且未勾选任何值 = 只看空集(与代发数据明细语义一致)。 + onApply(draft.mode === 'exclude' && draft.values.length === 0 ? null : { mode: draft.mode, values: draft.values }) + setOpen(false) + triggerRef.current?.focus() + } + + return ( + <> + + {open && createPortal( +
{ event.preventDefault(); if (!loading && !error) apply() }} + ref={popupRef} + role="dialog" + style={{ ...position, maxHeight: position.maxHeight }} + > +
+ {label} + +
+ +
+ + +
+
{loading ? '加载中' : error ? '加载失败' : `已选 ${selectedCount} / ${values.length} 项`}
+
setScrollTop(event.currentTarget.scrollTop)} + ref={listRef} + > + {loading ?
正在加载候选值
+ : error ?
{error}
+ : !matches.length ?
没有匹配的候选值
+ :
+
+ {matches.slice(windowStart, windowStart + WINDOW_SIZE).map((value) => ( + + ))} +
+
} +
+
+ + +
+ , document.body, + )} + + ) +} diff --git a/furniture-3d-system/src/lib/column-header-filters.test.ts b/furniture-3d-system/src/lib/column-header-filters.test.ts new file mode 100644 index 00000000..7def8b06 --- /dev/null +++ b/furniture-3d-system/src/lib/column-header-filters.test.ts @@ -0,0 +1,60 @@ +export {} + +import { describe, expect, it } from '@jest/globals' +import { + matchesColumnHeaderFilterSelection, + normalizeColumnHeaderFilterSelection, + serializeColumnHeaderFilters, + toColumnFilterValue, + type ColumnHeaderFilterSelection, +} from './column-header-filters' + +describe('column-header-filters', () => { + it('include 模式命中任一所选值即通过(多值列如供应商子行)', () => { + const selection: ColumnHeaderFilterSelection = { mode: 'include', values: ['供应商甲', '供应商乙'] } + expect(matchesColumnHeaderFilterSelection(selection, ['供应商乙'])).toBe(true) + expect(matchesColumnHeaderFilterSelection(selection, ['供应商丙', ''])).toBe(false) + }) + + it('exclude 模式为全不命中才通过', () => { + const selection: ColumnHeaderFilterSelection = { mode: 'exclude', values: ['DF-A1'] } + expect(matchesColumnHeaderFilterSelection(selection, ['DF-B1'])).toBe(true) + expect(matchesColumnHeaderFilterSelection(selection, ['DF-A1', 'DF-B1'])).toBe(false) + }) + + it('空值以空字符串参与筛选,可被 include/exclude', () => { + const includeEmpty: ColumnHeaderFilterSelection = { mode: 'include', values: [''] } + expect(matchesColumnHeaderFilterSelection(includeEmpty, [''])).toBe(true) + expect(matchesColumnHeaderFilterSelection(includeEmpty, ['有值'])).toBe(false) + const excludeEmpty: ColumnHeaderFilterSelection = { mode: 'exclude', values: [''] } + expect(matchesColumnHeaderFilterSelection(excludeEmpty, ['有值'])).toBe(true) + expect(matchesColumnHeaderFilterSelection(excludeEmpty, [''])).toBe(false) + }) + + it('无筛选时全部通过', () => { + expect(matchesColumnHeaderFilterSelection(null, ['任意'])).toBe(true) + expect(matchesColumnHeaderFilterSelection(undefined, [])).toBe(true) + }) + + it('normalize:exclude 空值视为无筛选,值去重排序', () => { + expect(normalizeColumnHeaderFilterSelection({ mode: 'exclude', values: [] })).toBeNull() + expect(normalizeColumnHeaderFilterSelection({ mode: 'include', values: [] })).toEqual({ mode: 'include', values: [] }) + expect(normalizeColumnHeaderFilterSelection({ mode: 'include', values: ['b', 'a', 'b'] })) + .toEqual({ mode: 'include', values: ['a', 'b'] }) + }) + + it('serialize 只保留白名单字段并跳过空筛选', () => { + expect(serializeColumnHeaderFilters({ + sku: { mode: 'exclude', values: ['X'] }, + spu: { mode: 'exclude', values: [] }, + barcode: null, + extra: { mode: 'include', values: ['Y'] }, + }, ['sku', 'spu', 'barcode'])).toBe('{"sku":{"mode":"exclude","values":["X"]}}') + }) + + it('toColumnFilterValue 把 null/undefined 归一为空字符串并去首尾空白', () => { + expect(toColumnFilterValue(null)).toBe('') + expect(toColumnFilterValue(undefined)).toBe('') + expect(toColumnFilterValue(' 值 ')).toBe('值') + }) +}) diff --git a/furniture-3d-system/src/lib/column-header-filters.ts b/furniture-3d-system/src/lib/column-header-filters.ts new file mode 100644 index 00000000..ba0d0ac8 --- /dev/null +++ b/furniture-3d-system/src/lib/column-header-filters.ts @@ -0,0 +1,55 @@ +import { z } from 'zod' + +/** + * 列头搜索筛选的通用模型与匹配逻辑。 + * 交互对齐 1688代发数据明细 已上线的 DropshipHeaderFilter: + * include = 命中任一所选值;exclude = 排除所选值;空值以空字符串参与筛选、展示为「(空值)」。 + */ + +export const COLUMN_HEADER_FILTER_MAX_VALUES = 100_000 + +export const columnHeaderFilterSelectionSchema = z.object({ + mode: z.enum(['include', 'exclude']), + values: z.array(z.string().max(2_048)).max(COLUMN_HEADER_FILTER_MAX_VALUES), +}).strict() + +export type ColumnHeaderFilterSelection = z.infer + +export function normalizeColumnHeaderFilterSelection( + selection: ColumnHeaderFilterSelection | null | undefined, +): ColumnHeaderFilterSelection | null { + if (!selection) return null + const values = [...new Set(selection.values.map((value) => String(value ?? '')))].sort((left, right) => left.localeCompare(right, 'zh-CN')) + // exclude 且未勾选任何值等于没有筛选。 + if (selection.mode === 'exclude' && values.length === 0) return null + return { mode: selection.mode, values } +} + +export function serializeColumnHeaderFilters( + filters: Record, + allowedFields: readonly string[], +): string { + const normalized: Record = {} + for (const field of allowedFields) { + const selection = normalizeColumnHeaderFilterSelection(filters[field]) + if (selection) normalized[field] = selection + } + return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : '' +} + +/** 行值匹配:extractedValues 为该行在该列下的全部取值(多值列如供应商子行会返回多个)。 */ +export function matchesColumnHeaderFilterSelection( + selection: ColumnHeaderFilterSelection | null | undefined, + extractedValues: string[], +): boolean { + if (!selection) return true + if (selection.values.length === 0) return selection.mode === 'include' + const candidates = extractedValues.map((value) => String(value ?? '')) + const hit = candidates.some((value) => selection.values.includes(value)) + return selection.mode === 'include' ? hit : !hit +} + +/** 把行值归一成可比较的字符串(null/undefined 归一为空字符串)。 */ +export function toColumnFilterValue(value: unknown): string { + return String(value ?? '').trim() +} diff --git a/furniture-3d-system/src/lib/finished-product-progress.ts b/furniture-3d-system/src/lib/finished-product-progress.ts index 0ea79ab3..e5295035 100644 --- a/furniture-3d-system/src/lib/finished-product-progress.ts +++ b/furniture-3d-system/src/lib/finished-product-progress.ts @@ -1,5 +1,11 @@ import { z } from 'zod' import { pinyin } from 'pinyin-pro' +import { + columnHeaderFilterSelectionSchema, + normalizeColumnHeaderFilterSelection, + serializeColumnHeaderFilters, + type ColumnHeaderFilterSelection, +} from '@/lib/column-header-filters' export const FINISHED_PRODUCT_PROGRESS_FIELDS = [ { key: 'orderPlacedDate', label: '下单日期' }, @@ -169,6 +175,7 @@ export const EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS = { currentNode: [] as string[], dueDateFrom: '', dueDateTo: '', + columnFilters: {}, } as const export type FinishedProductProgressFilters = { @@ -178,6 +185,59 @@ export type FinishedProductProgressFilters = { currentNode: string[] dueDateFrom: string dueDateTo: string + columnFilters: FinishedProductProgressColumnFilters +} + +// 列头搜索筛选支持的列(图片/日期/数量/操作列不参与)。 +export const FINISHED_PRODUCT_COLUMN_FILTER_FIELDS = [ + 'orderSequenceNo', + 'contractNumber', + 'followUpOwner', + 'itemNumber', + 'barcode', + 'productName', + 'supplier', + 'brand', + 'remarks', + 'currentNode', +] as const + +export type FinishedProductProgressColumnFilterField = typeof FINISHED_PRODUCT_COLUMN_FILTER_FIELDS[number] + +export type FinishedProductProgressColumnFilters = { + [K in FinishedProductProgressColumnFilterField]?: ColumnHeaderFilterSelection +} + +// 宽松 record:键在 normalize 时按白名单过滤,避免要求枚举键全量出现。 +export const finishedProductProgressColumnFiltersSchema = z.record( + z.string(), + columnHeaderFilterSelectionSchema, +) + +export function normalizeFinishedProductProgressColumnFilters(value: unknown): FinishedProductProgressColumnFilters { + if (!value || typeof value !== 'object') return {} + const source = value as Record + const normalized: FinishedProductProgressColumnFilters = {} + for (const field of FINISHED_PRODUCT_COLUMN_FILTER_FIELDS) { + const parsed = columnHeaderFilterSelectionSchema.safeParse(source[field]) + if (!parsed.success) continue + const selection = normalizeColumnHeaderFilterSelection(parsed.data) + if (selection) normalized[field] = selection + } + return normalized +} + +export function parseFinishedProductProgressColumnFilters(value: string | null | undefined): FinishedProductProgressColumnFilters { + if (!value) return {} + try { + return normalizeFinishedProductProgressColumnFilters(JSON.parse(value)) + } catch { + return {} + } +} + +export function serializeFinishedProductProgressColumnFilters(filters: FinishedProductProgressColumnFilters): string { + return serializeColumnHeaderFilters(filters, FINISHED_PRODUCT_COLUMN_FILTER_FIELDS) } export const finishedProductContractGroupUpdateSchema = z.object({ diff --git a/furniture-3d-system/src/server/repositories/finished-product-progress-record.ts b/furniture-3d-system/src/server/repositories/finished-product-progress-record.ts index ec052d58..7eb85ddc 100644 --- a/furniture-3d-system/src/server/repositories/finished-product-progress-record.ts +++ b/furniture-3d-system/src/server/repositories/finished-product-progress-record.ts @@ -1,5 +1,12 @@ import { Prisma } from '@prisma/client' -import { finishedProductSpuBase, resolveFinishedProductCurrentNode, type FinishedProductProgressFilters, type FinishedProductProgressInput } from '@/lib/finished-product-progress' +import { + finishedProductSpuBase, + resolveFinishedProductCurrentNode, + type FinishedProductProgressColumnFilterField, + type FinishedProductProgressFilters, + type FinishedProductProgressInput, +} from '@/lib/finished-product-progress' +import { matchesColumnHeaderFilterSelection, toColumnFilterValue } from '@/lib/column-header-filters' import { isFactoryIntakeRecord } from '@/lib/finished-product-progress-source' export const DELIVERY_RECORD_SELECT = { @@ -59,6 +66,29 @@ export function matchesCurrentNode(row: Pick matchesCurrentNode(row, params.filters))) + const sortedRows = sortFinishedProductRowsForList(rows.filter((row) => matchesCurrentNode(row, params.filters) && matchesFinishedProductColumnFilters(row, params.filters))) const pagedRows = sortedRows.slice(params.skip, params.skip + params.take) return { groups: pagedRows.map((row) => [row]), @@ -69,6 +70,16 @@ export class FinishedProductProgressRepository { } } + // 列头筛选候选值:按当前筛选过滤(跳过该列自身的列筛选)后去重,空值以空字符串参与。 + async listColumnFilterValues(params: { filters: FinishedProductProgressFilters; field: FinishedProductProgressColumnFilterField }) { + const where = buildWhere(params.filters) + const rows = await prisma.finishedProductProgressItem.findMany({ where, select: ITEM_SELECT }) + const values = rows + .filter((row) => matchesCurrentNode(row, params.filters) && matchesFinishedProductColumnFilters(row, params.filters, params.field)) + .map((row) => finishedProductColumnFilterValueOf(row, params.field)) + return [...new Set(values)].sort((left, right) => left.localeCompare(right, 'zh-CN')) + } + async listOptions() { const [suppliers, brands, currentNodes] = await Promise.all([ prisma.finishedProductProgressItem.findMany({ select: { supplier: true }, distinct: ['supplier'], orderBy: { supplier: 'asc' } }), diff --git a/furniture-3d-system/src/server/services/finished-product-progress.service.ts b/furniture-3d-system/src/server/services/finished-product-progress.service.ts index c686d95f..b3843532 100644 --- a/furniture-3d-system/src/server/services/finished-product-progress.service.ts +++ b/furniture-3d-system/src/server/services/finished-product-progress.service.ts @@ -5,7 +5,9 @@ import { extractOrderPlacedDateFromContractNumber, extractOrderPlacedDateFromRemarks, finishedProductContractGroupUpdateSchema, + normalizeFinishedProductProgressColumnFilters, type FinishedProductProgressChange, + type FinishedProductProgressColumnFilterField, type FinishedProductProgressFilters, type FinishedProductProgressImportProgress, type FinishedProductProgressSummaryRow, @@ -43,6 +45,7 @@ function cleanFilters(input?: Partial): Finished currentNode: list(input?.currentNode), dueDateFrom: String(input?.dueDateFrom ?? '').trim().slice(0, 200), dueDateTo: String(input?.dueDateTo ?? '').trim().slice(0, 200), + columnFilters: normalizeFinishedProductProgressColumnFilters(input?.columnFilters), } } @@ -128,6 +131,18 @@ export class FinishedProductProgressService { } } + // 列头筛选候选值:按当前筛选条件(不含该列自身的列筛选)过滤后去重。 + async listColumnFilterOptions(params: { + field: FinishedProductProgressColumnFilterField + filters?: Partial + user: AuthUser + }) { + await this.requireAccess(params.user, 'view') + const filters = cleanFilters(params.filters) + const values = await this.repository.listColumnFilterValues({ filters, field: params.field }) + return { values } + } + async create(input: unknown, user: AuthUser) { await this.requireAccess(user, 'edit') const values = initializeDueDates(await finishedProductDirectoryService.normalizeInput(parseInput(input)))
{column.label}
{filterableFields.has(column.key) ? {column.label} loadColumnFilterValues(column.key as FinishedProductProgressColumnFilterField)} selection={columnFilters[column.key as FinishedProductProgressColumnFilterField] ?? null} onApply={(selection) => applyColumnFilter(column.key as FinishedProductProgressColumnFilterField, selection)} /> : column.label}
onToggleSelect(row.id)} aria-label={`选择 ${row.barcode || row.itemNumber || row.id}`} />