feat(progress): add column header filters
This commit is contained in:
parent
53febe8eb7
commit
260fbc8428
@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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 })
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { ErrorCode } from '@/server/core/errors'
|
import { ErrorCode } from '@/server/core/errors'
|
||||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||||
|
import { parseFinishedProductProgressColumnFilters } from '@/lib/finished-product-progress'
|
||||||
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
|
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
|
||||||
|
|
||||||
const listSchema = z.object({
|
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([]),
|
currentNode: z.array(z.string().trim().min(1).max(200)).max(20).default([]),
|
||||||
dueDateFrom: z.string().trim().max(20).default(''),
|
dueDateFrom: z.string().trim().max(20).default(''),
|
||||||
dueDateTo: 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) {
|
export async function GET(request: NextRequest) {
|
||||||
@ -24,8 +26,13 @@ export async function GET(request: NextRequest) {
|
|||||||
currentNode: request.nextUrl.searchParams.getAll('currentNode'),
|
currentNode: request.nextUrl.searchParams.getAll('currentNode'),
|
||||||
})
|
})
|
||||||
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '成品进度筛选条件不正确', 400, ctx.requestId)
|
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '成品进度筛选条件不正确', 400, ctx.requestId)
|
||||||
const { page, pageSize, ...filters } = parsed.data
|
const { page, pageSize, columnFilters, ...filters } = parsed.data
|
||||||
return jsonSuccess(await finishedProductProgressService.listPage({ page, pageSize, filters, user: ctx.user }), 200, ctx.requestId)
|
return jsonSuccess(await finishedProductProgressService.listPage({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
filters: { ...filters, columnFilters: parseFinishedProductProgressColumnFilters(columnFilters) },
|
||||||
|
user: ctx.user,
|
||||||
|
}), 200, ctx.requestId)
|
||||||
}, { requireAuth: true })
|
}, { requireAuth: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { AlertTriangle, ArrowLeft, ArrowRight, CalendarClock, Link2, Plus, Search, X } from 'lucide-react'
|
import { AlertTriangle, ArrowLeft, ArrowRight, CalendarClock, Link2, Plus, Search, X } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
@ -11,12 +11,15 @@ import {
|
|||||||
SAMPLE_PROGRESS_STATUS_LABELS,
|
SAMPLE_PROGRESS_STATUS_LABELS,
|
||||||
SAMPLE_PROGRESS_STATUSES,
|
SAMPLE_PROGRESS_STATUSES,
|
||||||
} from '@/lib/sample-progress'
|
} from '@/lib/sample-progress'
|
||||||
|
import { matchesColumnHeaderFilterSelection, toColumnFilterValue, type ColumnHeaderFilterSelection } from '@/lib/column-header-filters'
|
||||||
import type { SampleProgressDTO } from '@/server/services/sample-progress.service'
|
import type { SampleProgressDTO } from '@/server/services/sample-progress.service'
|
||||||
|
import { ColumnHeaderFilter } from '@/components/common/ColumnHeaderFilter'
|
||||||
import SampleProgressColumnSettings from './components/SampleProgressColumnSettings'
|
import SampleProgressColumnSettings from './components/SampleProgressColumnSettings'
|
||||||
import SampleProgressEditorDrawer from './components/SampleProgressEditorDrawer'
|
import SampleProgressEditorDrawer from './components/SampleProgressEditorDrawer'
|
||||||
import SampleProgressTableRow from './components/SampleProgressTableRow'
|
import SampleProgressTableRow from './components/SampleProgressTableRow'
|
||||||
import { isSampleProgressSupplierOverdue, sampleProgressSuppliers } from './sampleProgressSuppliers'
|
import { isSampleProgressSupplierOverdue, sampleProgressSuppliers } from './sampleProgressSuppliers'
|
||||||
import { useSampleProgressColumnVisibility } from './useSampleProgressColumnVisibility'
|
import { useSampleProgressColumnVisibility } from './useSampleProgressColumnVisibility'
|
||||||
|
import type { SampleProgressColumnKey } from './sampleProgressColumns'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
const utcToday = () => {
|
const utcToday = () => {
|
||||||
@ -26,11 +29,27 @@ const utcToday = () => {
|
|||||||
const isOverdue = (item: SampleProgressDTO) => sampleProgressSuppliers(item)
|
const isOverdue = (item: SampleProgressDTO) => sampleProgressSuppliers(item)
|
||||||
.some((supplier) => isSampleProgressSupplierOverdue(supplier, utcToday()))
|
.some((supplier) => isSampleProgressSupplierOverdue(supplier, utcToday()))
|
||||||
|
|
||||||
|
// 列头筛选的行取值:供应商/快递单号/进度说明是供应商子行多值列(任一子行命中即算命中)。
|
||||||
|
export const SAMPLE_PROGRESS_COLUMN_FILTER_EXTRACTORS: Partial<Record<SampleProgressColumnKey, (item: SampleProgressDTO) => 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 }) {
|
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 [items, setItems] = useState(initialItems)
|
||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([])
|
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([])
|
||||||
const [overdueOnly, setOverdueOnly] = useState(false)
|
const [overdueOnly, setOverdueOnly] = useState(false)
|
||||||
|
const [columnFilters, setColumnFilters] = useState<Partial<Record<SampleProgressColumnKey, ColumnHeaderFilterSelection>>>({})
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageInput, setPageInput] = useState('1')
|
const [pageInput, setPageInput] = useState('1')
|
||||||
const [editorItem, setEditorItem] = useState<SampleProgressDTO | 'new' | null>(null)
|
const [editorItem, setEditorItem] = useState<SampleProgressDTO | 'new' | null>(null)
|
||||||
@ -43,8 +62,7 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
|
|||||||
const canDelete = permissionCanOperate && isSampleProgressAdministrator(currentUserRole)
|
const canDelete = permissionCanOperate && isSampleProgressAdministrator(currentUserRole)
|
||||||
|
|
||||||
const currentStageCodes = useMemo(() => new Set(currentSampleStageOpeningCodes.map((code) => code.trim().toUpperCase())), [currentSampleStageOpeningCodes])
|
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 baseFiltered = useMemo(() => items.filter((item) => {
|
||||||
const filtered = useMemo(() => items.filter((item) => {
|
|
||||||
const supplierKeywords = sampleProgressSuppliers(item).flatMap((supplier) => [supplier.supplier, supplier.courierTrackingNumber, supplier.progressDescription])
|
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 haystack = [item.openingCode, item.brand, item.designer, item.purchaseOwner, item.color, item.material, ...supplierKeywords].join(' ').toLowerCase()
|
||||||
const normalizedStatus = normalizeSampleProgressStatus(item.status)
|
const normalizedStatus = normalizeSampleProgressStatus(item.status)
|
||||||
@ -55,6 +73,29 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
|
|||||||
: currentStageCodes.has(item.openingCode.trim().toUpperCase()) && !isSampleProgressTerminalStatus(normalizedStatus)
|
: currentStageCodes.has(item.openingCode.trim().toUpperCase()) && !isSampleProgressTerminalStatus(normalizedStatus)
|
||||||
return (!keyword || haystack.includes(keyword.toLowerCase())) && statusMatches && (!overdueOnly || isOverdue(item))
|
return (!keyword || haystack.includes(keyword.toLowerCase())) && statusMatches && (!overdueOnly || isOverdue(item))
|
||||||
}), [currentStageCodes, items, keyword, overdueOnly, selectedStatuses])
|
}), [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 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 rows = filtered.slice((Math.min(page, totalPages) - 1) * PAGE_SIZE, Math.min(page, totalPages) * PAGE_SIZE)
|
||||||
const overdueCount = currentStageItems.filter(isOverdue).length
|
const overdueCount = currentStageItems.filter(isOverdue).length
|
||||||
@ -95,6 +136,7 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
|
|||||||
setKeyword('')
|
setKeyword('')
|
||||||
setSelectedStatuses([])
|
setSelectedStatuses([])
|
||||||
setOverdueOnly(false)
|
setOverdueOnly(false)
|
||||||
|
setColumnFilters({})
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,7 +202,10 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
|
|||||||
<div className="box-border min-h-0 flex-1 overflow-auto overscroll-contain pb-[76px]">
|
<div className="box-border min-h-0 flex-1 overflow-auto overscroll-contain pb-[76px]">
|
||||||
<table className="w-full border-separate border-spacing-0 text-center text-[11px]" style={{ minWidth: tableMinWidth }}>
|
<table className="w-full border-separate border-spacing-0 text-center text-[11px]" style={{ minWidth: tableMinWidth }}>
|
||||||
<thead className="text-[11px] font-bold text-[#657074]">
|
<thead className="text-[11px] font-bold text-[#657074]">
|
||||||
<tr>{columnVisibility.visibleColumns.map((column) => <th key={column.key} data-column-key={column.key} style={{ minWidth: column.minWidth }} className={`sticky top-0 z-20 whitespace-nowrap border-b border-black/[0.07] bg-[#eef0ef] px-3 py-3 ${column.headerClassName ?? ''}`}>{column.label}</th>)}</tr>
|
<tr>{columnVisibility.visibleColumns.map((column) => {
|
||||||
|
const extractor = SAMPLE_PROGRESS_COLUMN_FILTER_EXTRACTORS[column.key]
|
||||||
|
return <th key={column.key} data-column-key={column.key} style={{ minWidth: column.minWidth }} className={`sticky top-0 z-20 whitespace-nowrap border-b border-black/[0.07] bg-[#eef0ef] px-3 py-3 ${column.headerClassName ?? ''}`}>{extractor ? <span className="inline-flex items-center gap-1"><span>{column.label}</span><ColumnHeaderFilter label={column.label} active={Boolean(columnFilters[column.key])} disabled={editorItem !== null} loadValues={() => loadColumnFilterCandidates(column.key)} selection={columnFilters[column.key] ?? null} onApply={(selection) => applyColumnFilter(column.key, selection)} /></span> : column.label}</th>
|
||||||
|
})}</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((item) => <SampleProgressTableRow key={item.id} item={item} visibleColumnKeys={columnVisibility.visibleColumnKeySet} overdue={isOverdue(item)} canEdit={canEdit(item)} canDelete={canDelete} onStartEdit={() => setEditorItem(item)} onView={setViewItem} onHistory={setHistoryItem} onDelete={(row) => void remove(row)} onPreview={setPreviewImage}/>) }
|
{rows.map((item) => <SampleProgressTableRow key={item.id} item={item} visibleColumnKeys={columnVisibility.visibleColumnKeySet} overdue={isOverdue(item)} canEdit={canEdit(item)} canDelete={canDelete} onStartEdit={() => setEditorItem(item)} onView={setViewItem} onHistory={setHistoryItem} onDelete={(row) => void remove(row)} onPreview={setPreviewImage}/>) }
|
||||||
|
|||||||
@ -5,11 +5,14 @@ import { useCallback, useRef, useState } from 'react'
|
|||||||
import { FileDown, Layers3, Plus, RotateCcw, Search, Trash2, Upload, X } from 'lucide-react'
|
import { FileDown, Layers3, Plus, RotateCcw, Search, Trash2, Upload, X } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS,
|
EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS,
|
||||||
|
serializeFinishedProductProgressColumnFilters,
|
||||||
type FinishedProductProgressChangeLogEntry,
|
type FinishedProductProgressChangeLogEntry,
|
||||||
|
type FinishedProductProgressColumnFilterField,
|
||||||
type FinishedProductProgressDetailRow,
|
type FinishedProductProgressDetailRow,
|
||||||
type FinishedProductProgressFilters,
|
type FinishedProductProgressFilters,
|
||||||
type FinishedProductProgressPage,
|
type FinishedProductProgressPage,
|
||||||
} from '@/lib/finished-product-progress'
|
} from '@/lib/finished-product-progress'
|
||||||
|
import type { ColumnHeaderFilterSelection } from '@/lib/column-header-filters'
|
||||||
import { FinishedProductColumnSettings } from './components/FinishedProductColumnSettings'
|
import { FinishedProductColumnSettings } from './components/FinishedProductColumnSettings'
|
||||||
import { FinishedProductEditorDrawer } from './components/FinishedProductEditorDrawer'
|
import { FinishedProductEditorDrawer } from './components/FinishedProductEditorDrawer'
|
||||||
import { FinishedProductExportProgress } from './components/FinishedProductExportProgress'
|
import { FinishedProductExportProgress } from './components/FinishedProductExportProgress'
|
||||||
@ -64,10 +67,13 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
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))
|
if (Array.isArray(value)) value.forEach((entry) => params.append(key, entry))
|
||||||
else if (value) params.set(key, value)
|
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
|
const next = await readResponse(await fetch(`/api/supply-chain/finished-product-progress?${params}`, { cache: 'no-store' })) as FinishedProductProgressPage
|
||||||
setData(next)
|
setData(next)
|
||||||
setFilters(nextFilters)
|
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 afterImport = useCallback(() => load(filters, 1, data.pageInfo.pageSize), [data.pageInfo.pageSize, filters, load])
|
||||||
const importTask = useFinishedProductImport(afterImport)
|
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 reset = () => {
|
||||||
const next = { ...EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS }
|
const next = { ...EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS }
|
||||||
setFilters(next)
|
setFilters(next)
|
||||||
@ -160,7 +193,7 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca
|
|||||||
<button type="submit" className="finished-primary-button"><Search size={15} />查询</button><button type="button" title="重置筛选" aria-label="重置筛选" onClick={reset} className="finished-control-button"><RotateCcw size={15} /></button>
|
<button type="submit" className="finished-primary-button"><Search size={15} />查询</button><button type="button" title="重置筛选" aria-label="重置筛选" onClick={reset} className="finished-control-button"><RotateCcw size={15} /></button>
|
||||||
</form>
|
</form>
|
||||||
<section className="finished-product-table-shell">
|
<section className="finished-product-table-shell">
|
||||||
<FinishedProductTable rows={data.rows} columns={columns.visibleColumns} canEdit={canEdit} selectedIds={selectedIds} allSelected={allSelected} onToggleSelect={(id) => setSelectedIds((current) => {
|
<FinishedProductTable rows={data.rows} columns={columns.visibleColumns} canEdit={canEdit} selectedIds={selectedIds} allSelected={allSelected} columnFilters={filters.columnFilters} onLoadColumnFilterValues={loadColumnFilterValues} onApplyColumnFilter={applyColumnFilter} onToggleSelect={(id) => setSelectedIds((current) => {
|
||||||
const next = new Set(current)
|
const next = new Set(current)
|
||||||
if (next.has(id)) next.delete(id)
|
if (next.has(id)) next.delete(id)
|
||||||
else next.add(id)
|
else next.add(id)
|
||||||
|
|||||||
@ -2,16 +2,21 @@
|
|||||||
|
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { ExternalLink, Eye, History, ListChecks, Pencil, Trash2 } from 'lucide-react'
|
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'
|
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<string>; 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<string>; allSelected: boolean; columnFilters?: FinishedProductProgressColumnFilters; onLoadColumnFilterValues?: (field: FinishedProductProgressColumnFilterField) => Promise<string[]>; 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 minWidth = columns.reduce((total, column) => total + column.minWidth, 0)
|
||||||
const detailCount = rows.reduce((total, group) => total + group.details.length, 0)
|
const detailCount = rows.reduce((total, group) => total + group.details.length, 0)
|
||||||
const tableRows = buildFinishedProductTableRows(rows)
|
const tableRows = buildFinishedProductTableRows(rows)
|
||||||
|
const filterableFields = new Set<string>(FINISHED_PRODUCT_COLUMN_FILTER_FIELDS)
|
||||||
|
const applyColumnFilter = onApplyColumnFilter ?? (() => {})
|
||||||
|
const loadColumnFilterValues = onLoadColumnFilterValues ?? (() => Promise.resolve([]))
|
||||||
return <div className="min-h-0 flex-1 overflow-auto overscroll-contain">
|
return <div className="min-h-0 flex-1 overflow-auto overscroll-contain">
|
||||||
<table className="w-full border-separate border-spacing-0 text-center text-xs" style={{ minWidth: minWidth + (canEdit ? 44 : 0) }}>
|
<table className="w-full border-separate border-spacing-0 text-center text-xs" style={{ minWidth: minWidth + (canEdit ? 44 : 0) }}>
|
||||||
<thead className="text-[11px] font-bold text-zinc-600"><tr>{canEdit && <th aria-label="选择" style={{ width: 44, minWidth: 44, maxWidth: 44 }} className="sticky left-0 top-0 z-40 w-11 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-2 py-3"><input type="checkbox" checked={allSelected} disabled={detailCount === 0} onChange={onToggleAll} aria-label="全选当前页" /></th>}{columns.map((column) => <th key={column.key} style={{ minWidth: column.minWidth }} className={`sticky top-0 z-20 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-3 py-3 ${shiftStickyClass(column.stickyClassName, canEdit)}`}>{column.label}</th>)}</tr></thead>
|
<thead className="text-[11px] font-bold text-zinc-600"><tr>{canEdit && <th aria-label="选择" style={{ width: 44, minWidth: 44, maxWidth: 44 }} className="sticky left-0 top-0 z-40 w-11 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-2 py-3"><input type="checkbox" checked={allSelected} disabled={detailCount === 0} onChange={onToggleAll} aria-label="全选当前页" /></th>}{columns.map((column) => <th key={column.key} style={{ minWidth: column.minWidth }} className={`sticky top-0 z-20 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-3 py-3 ${shiftStickyClass(column.stickyClassName, canEdit)}`}>{filterableFields.has(column.key) ? <span className="inline-flex items-center gap-1"><span>{column.label}</span><ColumnHeaderFilter label={column.label} active={Boolean(columnFilters[column.key as FinishedProductProgressColumnFilterField])} loadValues={() => loadColumnFilterValues(column.key as FinishedProductProgressColumnFilterField)} selection={columnFilters[column.key as FinishedProductProgressColumnFilterField] ?? null} onApply={(selection) => applyColumnFilter(column.key as FinishedProductProgressColumnFilterField, selection)} /></span> : column.label}</th>)}</tr></thead>
|
||||||
<tbody>{tableRows.map(({ row, isContractFirst, contractRowSpan, contractRowIds, contractBandClass }) => <tr key={row.id} className={`group hover:bg-zinc-50 ${isContractFirst ? 'finished-contract-start' : ''}`}>
|
<tbody>{tableRows.map(({ row, isContractFirst, contractRowSpan, contractRowIds, contractBandClass }) => <tr key={row.id} className={`group hover:bg-zinc-50 ${isContractFirst ? 'finished-contract-start' : ''}`}>
|
||||||
{canEdit && <td className={`finished-product-select-cell sticky left-0 z-10 border-b border-zinc-100 px-2 py-3 align-middle group-hover:bg-zinc-50 ${contractBandClass}`}><input type="checkbox" checked={selectedIds.has(row.id)} onChange={() => onToggleSelect(row.id)} aria-label={`选择 ${row.barcode || row.itemNumber || row.id}`} /></td>}
|
{canEdit && <td className={`finished-product-select-cell sticky left-0 z-10 border-b border-zinc-100 px-2 py-3 align-middle group-hover:bg-zinc-50 ${contractBandClass}`}><input type="checkbox" checked={selectedIds.has(row.id)} onChange={() => onToggleSelect(row.id)} aria-label={`选择 ${row.barcode || row.itemNumber || row.id}`} /></td>}
|
||||||
{columns.map((column) => column.key === 'orderSequenceNo'
|
{columns.map((column) => column.key === 'orderSequenceNo'
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from 'react'
|
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
|
const PAGE_SIZE = 200
|
||||||
|
|
||||||
@ -42,10 +42,13 @@ export function useFinishedProductExport() {
|
|||||||
let total = Number.POSITIVE_INFINITY
|
let total = Number.POSITIVE_INFINITY
|
||||||
do {
|
do {
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) })
|
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))
|
if (Array.isArray(value)) value.forEach((entry) => params.append(key, entry))
|
||||||
else if (value) params.set(key, value)
|
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 response = await fetch(`/api/supply-chain/finished-product-progress?${params.toString()}`, { cache: 'no-store' })
|
||||||
const payload = await response.json().catch(() => null)
|
const payload = await response.json().catch(() => null)
|
||||||
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '导出数据拉取失败')
|
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '导出数据拉取失败')
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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(<ColumnHeaderFilter {...baseProps} />)
|
||||||
|
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(<ColumnHeaderFilter {...baseProps} onApply={onApply} />)
|
||||||
|
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(<ColumnHeaderFilter {...baseProps} onApply={onApply} />)
|
||||||
|
const dialog = await open()
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: '应用' }))
|
||||||
|
expect(onApply).toHaveBeenCalledWith(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('默认 exclude 语义:直接排除所选值', async () => {
|
||||||
|
const onApply = jest.fn()
|
||||||
|
render(<ColumnHeaderFilter {...baseProps} onApply={onApply} />)
|
||||||
|
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(<ColumnHeaderFilter {...baseProps} selection={{ mode: 'include', values: ['供应商甲'] }} onApply={onApply} />)
|
||||||
|
const dialog = await open()
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: '清除筛选' }))
|
||||||
|
expect(onApply).toHaveBeenCalledWith(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('搜索框过滤候选值并支持全选搜索结果', async () => {
|
||||||
|
const onApply = jest.fn()
|
||||||
|
render(<ColumnHeaderFilter {...baseProps} selection={{ mode: 'include', values: [] }} onApply={onApply} />)
|
||||||
|
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(<ColumnHeaderFilter {...baseProps} />)
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
239
furniture-3d-system/src/components/common/ColumnHeaderFilter.tsx
Normal file
239
furniture-3d-system/src/components/common/ColumnHeaderFilter.tsx
Normal file
@ -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<string[]>
|
||||||
|
selection: ColumnHeaderFilterSelection | null
|
||||||
|
onApply: (selection: ColumnHeaderFilterSelection | null) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [draft, setDraft] = useState<DraftSelection>({ mode: 'exclude', values: [] })
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [values, setValues] = useState<string[]>([])
|
||||||
|
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<HTMLButtonElement>(null)
|
||||||
|
const popupRef = useRef<HTMLFormElement>(null)
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
const allRef = useRef<HTMLInputElement>(null)
|
||||||
|
const listRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
aria-label={`筛选${label}`}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
className={`${styles.columnFilterTrigger} ${active ? styles.columnFilterActive : ''}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
setDraft(selection ?? { mode: 'exclude', values: [] })
|
||||||
|
setSearch('')
|
||||||
|
setScrollTop(0)
|
||||||
|
setValues([])
|
||||||
|
setOpen(!open)
|
||||||
|
}}
|
||||||
|
ref={triggerRef}
|
||||||
|
title={`筛选${label}${active ? '(已筛选)' : ''}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Filter size={13} />
|
||||||
|
</button>
|
||||||
|
{open && createPortal(
|
||||||
|
<form
|
||||||
|
aria-label={`筛选${label}`}
|
||||||
|
className={styles.columnFilterPopover}
|
||||||
|
onSubmit={(event) => { event.preventDefault(); if (!loading && !error) apply() }}
|
||||||
|
ref={popupRef}
|
||||||
|
role="dialog"
|
||||||
|
style={{ ...position, maxHeight: position.maxHeight }}
|
||||||
|
>
|
||||||
|
<div className={styles.columnFilterHeading}>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<button aria-label="关闭筛选" onClick={() => { setOpen(false); triggerRef.current?.focus() }} title="关闭" type="button"><X size={15} /></button>
|
||||||
|
</div>
|
||||||
|
<label className={styles.columnFilterSearch}>
|
||||||
|
<Search size={14} />
|
||||||
|
<input
|
||||||
|
aria-label={`搜索${label}候选值`}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value)
|
||||||
|
setScrollTop(0)
|
||||||
|
if (listRef.current) listRef.current.scrollTop = 0
|
||||||
|
}}
|
||||||
|
placeholder={`搜索${label}`}
|
||||||
|
ref={searchRef}
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className={styles.columnFilterSelection}>
|
||||||
|
<label>
|
||||||
|
<input checked={allChecked} disabled={loading || !!error || !matches.length} onChange={toggleAll} ref={allRef} type="checkbox" />
|
||||||
|
<span>{search.trim() ? '全选搜索结果' : '全选'}</span>
|
||||||
|
</label>
|
||||||
|
<button disabled={loading || !!error} onClick={() => setDraft({ mode: 'include', values: [] })} type="button">全不选</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.columnFilterCount}>{loading ? '加载中' : error ? '加载失败' : `已选 ${selectedCount} / ${values.length} 项`}</div>
|
||||||
|
<div
|
||||||
|
aria-label={`${label}候选值`}
|
||||||
|
className={styles.columnFilterOptions}
|
||||||
|
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
|
||||||
|
ref={listRef}
|
||||||
|
>
|
||||||
|
{loading ? <div className={styles.columnFilterMessage}><LoaderCircle size={16} />正在加载候选值</div>
|
||||||
|
: error ? <div className={styles.columnFilterMessage} role="alert">{error}<button onClick={() => setRetry(retry + 1)} type="button">重试</button></div>
|
||||||
|
: !matches.length ? <div className={styles.columnFilterMessage}>没有匹配的候选值</div>
|
||||||
|
: <div style={{ height: matches.length * ROW_HEIGHT, position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', top: windowStart * ROW_HEIGHT, left: 0, right: 0 }}>
|
||||||
|
{matches.slice(windowStart, windowStart + WINDOW_SIZE).map((value) => (
|
||||||
|
<label className={styles.columnFilterOption} key={value || '__EMPTY__'}>
|
||||||
|
<input checked={isChecked(value)} onChange={() => toggle(value)} type="checkbox" />
|
||||||
|
<span title={value || '(空值)'}>{value || '(空值)'}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.columnFilterActions}>
|
||||||
|
<button disabled={loading || !!error} onClick={() => { onApply(null); setOpen(false); triggerRef.current?.focus() }} type="button">清除筛选</button>
|
||||||
|
<button className={styles.columnFilterApply} disabled={loading || !!error} type="submit"><Check size={14} />应用</button>
|
||||||
|
</div>
|
||||||
|
</form>, document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
furniture-3d-system/src/lib/column-header-filters.test.ts
Normal file
60
furniture-3d-system/src/lib/column-header-filters.test.ts
Normal file
@ -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('值')
|
||||||
|
})
|
||||||
|
})
|
||||||
55
furniture-3d-system/src/lib/column-header-filters.ts
Normal file
55
furniture-3d-system/src/lib/column-header-filters.ts
Normal file
@ -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<typeof columnHeaderFilterSelectionSchema>
|
||||||
|
|
||||||
|
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<string, ColumnHeaderFilterSelection | null | undefined>,
|
||||||
|
allowedFields: readonly string[],
|
||||||
|
): string {
|
||||||
|
const normalized: Record<string, ColumnHeaderFilterSelection> = {}
|
||||||
|
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()
|
||||||
|
}
|
||||||
@ -1,5 +1,11 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { pinyin } from 'pinyin-pro'
|
import { pinyin } from 'pinyin-pro'
|
||||||
|
import {
|
||||||
|
columnHeaderFilterSelectionSchema,
|
||||||
|
normalizeColumnHeaderFilterSelection,
|
||||||
|
serializeColumnHeaderFilters,
|
||||||
|
type ColumnHeaderFilterSelection,
|
||||||
|
} from '@/lib/column-header-filters'
|
||||||
|
|
||||||
export const FINISHED_PRODUCT_PROGRESS_FIELDS = [
|
export const FINISHED_PRODUCT_PROGRESS_FIELDS = [
|
||||||
{ key: 'orderPlacedDate', label: '下单日期' },
|
{ key: 'orderPlacedDate', label: '下单日期' },
|
||||||
@ -169,6 +175,7 @@ export const EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS = {
|
|||||||
currentNode: [] as string[],
|
currentNode: [] as string[],
|
||||||
dueDateFrom: '',
|
dueDateFrom: '',
|
||||||
dueDateTo: '',
|
dueDateTo: '',
|
||||||
|
columnFilters: {},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type FinishedProductProgressFilters = {
|
export type FinishedProductProgressFilters = {
|
||||||
@ -178,6 +185,59 @@ export type FinishedProductProgressFilters = {
|
|||||||
currentNode: string[]
|
currentNode: string[]
|
||||||
dueDateFrom: string
|
dueDateFrom: string
|
||||||
dueDateTo: 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<string, unknown>
|
||||||
|
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({
|
export const finishedProductContractGroupUpdateSchema = z.object({
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
import { Prisma } from '@prisma/client'
|
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'
|
import { isFactoryIntakeRecord } from '@/lib/finished-product-progress-source'
|
||||||
|
|
||||||
export const DELIVERY_RECORD_SELECT = {
|
export const DELIVERY_RECORD_SELECT = {
|
||||||
@ -59,6 +66,29 @@ export function matchesCurrentNode(row: Pick<FinishedProductProgressRecord, 'con
|
|||||||
return filters.currentNode.length === 0 || filters.currentNode.includes(resolveFinishedProductCurrentNode(row.contractNumber, row.orderedQuantity, row.deliveryQuantity, row.orderPlacedDate))
|
return filters.currentNode.length === 0 || filters.currentNode.includes(resolveFinishedProductCurrentNode(row.contractNumber, row.orderedQuantity, row.deliveryQuantity, row.orderPlacedDate))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 列头筛选的行取值:currentNode 是派生值,必须内存计算;其余列直接取记录字段。 */
|
||||||
|
export function finishedProductColumnFilterValueOf(row: FinishedProductProgressRecord, field: FinishedProductProgressColumnFilterField): string {
|
||||||
|
if (field === 'currentNode') {
|
||||||
|
return resolveFinishedProductCurrentNode(row.contractNumber, row.orderedQuantity, row.deliveryQuantity, row.orderPlacedDate)
|
||||||
|
}
|
||||||
|
return toColumnFilterValue(row[field])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用列头筛选(include=命中任一所选值 / exclude=排除所选值;excludeField 用于候选值计算时跳过该列自身)。 */
|
||||||
|
export function matchesFinishedProductColumnFilters(
|
||||||
|
row: FinishedProductProgressRecord,
|
||||||
|
filters: FinishedProductProgressFilters,
|
||||||
|
excludeField?: FinishedProductProgressColumnFilterField,
|
||||||
|
) {
|
||||||
|
for (const [field, selection] of Object.entries(filters.columnFilters ?? {})) {
|
||||||
|
if (!selection || field === excludeField) continue
|
||||||
|
if (!matchesColumnHeaderFilterSelection(selection, [finishedProductColumnFilterValueOf(row, field as FinishedProductProgressColumnFilterField)])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export function dataValues(input: FinishedProductProgressInput) {
|
export function dataValues(input: FinishedProductProgressInput) {
|
||||||
const { orderDueDateChangeReason: _reason, supplierCode, ...mutableInput } = input
|
const { orderDueDateChangeReason: _reason, supplierCode, ...mutableInput } = input
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
buildOrderSequenceNo,
|
buildOrderSequenceNo,
|
||||||
buildOrderSequencePrefix,
|
buildOrderSequencePrefix,
|
||||||
resolveFinishedProductCurrentNode,
|
resolveFinishedProductCurrentNode,
|
||||||
|
type FinishedProductProgressColumnFilterField,
|
||||||
type FinishedProductProgressFilters,
|
type FinishedProductProgressFilters,
|
||||||
type FinishedProductProgressInput,
|
type FinishedProductProgressInput,
|
||||||
} from '@/lib/finished-product-progress'
|
} from '@/lib/finished-product-progress'
|
||||||
@ -13,7 +14,7 @@ import { isFactoryIntakeRecord } from '@/lib/finished-product-progress-source'
|
|||||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ITEM_SELECT, DELIVERY_RECORD_SELECT, buildWhere, matchesCurrentNode, dataValues, sortFinishedProductRowsForList,
|
ITEM_SELECT, DELIVERY_RECORD_SELECT, buildWhere, matchesCurrentNode, matchesFinishedProductColumnFilters, finishedProductColumnFilterValueOf, dataValues, sortFinishedProductRowsForList,
|
||||||
} from '@/server/repositories/finished-product-progress-record'
|
} from '@/server/repositories/finished-product-progress-record'
|
||||||
export { ITEM_SELECT, dataValues, sortFinishedProductRowsForList } from '@/server/repositories/finished-product-progress-record'
|
export { ITEM_SELECT, dataValues, sortFinishedProductRowsForList } from '@/server/repositories/finished-product-progress-record'
|
||||||
export type { FinishedProductProgressRecord, FinishedProductDeliveryRecord } from '@/server/repositories/finished-product-progress-record'
|
export type { FinishedProductProgressRecord, FinishedProductDeliveryRecord } from '@/server/repositories/finished-product-progress-record'
|
||||||
@ -61,7 +62,7 @@ export class FinishedProductProgressRepository {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
// 行身份重设计后每条记录即一笔独立订单,平铺返回
|
// 行身份重设计后每条记录即一笔独立订单,平铺返回
|
||||||
const sortedRows = sortFinishedProductRowsForList(rows.filter((row) => 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)
|
const pagedRows = sortedRows.slice(params.skip, params.skip + params.take)
|
||||||
return {
|
return {
|
||||||
groups: pagedRows.map((row) => [row]),
|
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() {
|
async listOptions() {
|
||||||
const [suppliers, brands, currentNodes] = await Promise.all([
|
const [suppliers, brands, currentNodes] = await Promise.all([
|
||||||
prisma.finishedProductProgressItem.findMany({ select: { supplier: true }, distinct: ['supplier'], orderBy: { supplier: 'asc' } }),
|
prisma.finishedProductProgressItem.findMany({ select: { supplier: true }, distinct: ['supplier'], orderBy: { supplier: 'asc' } }),
|
||||||
|
|||||||
@ -5,7 +5,9 @@ import {
|
|||||||
extractOrderPlacedDateFromContractNumber,
|
extractOrderPlacedDateFromContractNumber,
|
||||||
extractOrderPlacedDateFromRemarks,
|
extractOrderPlacedDateFromRemarks,
|
||||||
finishedProductContractGroupUpdateSchema,
|
finishedProductContractGroupUpdateSchema,
|
||||||
|
normalizeFinishedProductProgressColumnFilters,
|
||||||
type FinishedProductProgressChange,
|
type FinishedProductProgressChange,
|
||||||
|
type FinishedProductProgressColumnFilterField,
|
||||||
type FinishedProductProgressFilters,
|
type FinishedProductProgressFilters,
|
||||||
type FinishedProductProgressImportProgress,
|
type FinishedProductProgressImportProgress,
|
||||||
type FinishedProductProgressSummaryRow,
|
type FinishedProductProgressSummaryRow,
|
||||||
@ -43,6 +45,7 @@ function cleanFilters(input?: Partial<FinishedProductProgressFilters>): Finished
|
|||||||
currentNode: list(input?.currentNode),
|
currentNode: list(input?.currentNode),
|
||||||
dueDateFrom: String(input?.dueDateFrom ?? '').trim().slice(0, 200),
|
dueDateFrom: String(input?.dueDateFrom ?? '').trim().slice(0, 200),
|
||||||
dueDateTo: String(input?.dueDateTo ?? '').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<FinishedProductProgressFilters>
|
||||||
|
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) {
|
async create(input: unknown, user: AuthUser) {
|
||||||
await this.requireAccess(user, 'edit')
|
await this.requireAccess(user, 'edit')
|
||||||
const values = initializeDueDates(await finishedProductDirectoryService.normalizeInput(parseInput(input)))
|
const values = initializeDueDates(await finishedProductDirectoryService.normalizeInput(parseInput(input)))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user