feat: submit approved sample and finished progress updates
This commit is contained in:
parent
b1505b4d63
commit
865be95b6e
@ -6,6 +6,8 @@ import { AlertTriangle, ArrowLeft, ArrowRight, CalendarClock, Link2, Plus, Searc
|
||||
import {
|
||||
canEditSampleProgress,
|
||||
isSampleProgressAdministrator,
|
||||
isSampleProgressTerminalStatus,
|
||||
normalizeSampleProgressStatus,
|
||||
SAMPLE_PROGRESS_STATUS_LABELS,
|
||||
SAMPLE_PROGRESS_STATUSES,
|
||||
} from '@/lib/sample-progress'
|
||||
@ -27,11 +29,12 @@ const isOverdue = (item: SampleProgressDTO) => sampleProgressSuppliers(item)
|
||||
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 [status, setStatus] = useState('')
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([])
|
||||
const [overdueOnly, setOverdueOnly] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageInput, setPageInput] = useState('1')
|
||||
const [editorItem, setEditorItem] = useState<SampleProgressDTO | 'new' | null>(null)
|
||||
const [viewItem, setViewItem] = useState<SampleProgressDTO | null>(null)
|
||||
const [historyItem, setHistoryItem] = useState<SampleProgressDTO | null>(null)
|
||||
const [previewImage, setPreviewImage] = useState<SampleProgressDTO | null>(null)
|
||||
const columnVisibility = useSampleProgressColumnVisibility(currentUserName)
|
||||
@ -40,17 +43,22 @@ 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())), [currentStageCodes, items])
|
||||
const currentStageItems = useMemo(() => items.filter((item) => currentStageCodes.has(item.openingCode.trim().toUpperCase()) && !isSampleProgressTerminalStatus(item.status)), [currentStageCodes, items])
|
||||
const filtered = 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 statusMatches = status === 'ALL' || (!status ? currentStageCodes.has(item.openingCode.trim().toUpperCase()) : item.status === status)
|
||||
const normalizedStatus = normalizeSampleProgressStatus(item.status)
|
||||
const statusMatches = selectedStatuses.includes('ALL')
|
||||
? true
|
||||
: selectedStatuses.length > 0
|
||||
? selectedStatuses.includes(normalizedStatus)
|
||||
: currentStageCodes.has(item.openingCode.trim().toUpperCase()) && !isSampleProgressTerminalStatus(normalizedStatus)
|
||||
return (!keyword || haystack.includes(keyword.toLowerCase())) && statusMatches && (!overdueOnly || isOverdue(item))
|
||||
}), [currentStageCodes, items, keyword, overdueOnly, status])
|
||||
}), [currentStageCodes, items, keyword, overdueOnly, selectedStatuses])
|
||||
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
|
||||
const statusCounts = SAMPLE_PROGRESS_STATUSES.map((value) => ({ value, count: items.filter((item) => item.status === value).length }))
|
||||
const statusCounts = SAMPLE_PROGRESS_STATUSES.map((value) => ({ value, count: items.filter((item) => normalizeSampleProgressStatus(item.status) === value).length }))
|
||||
const tableMinWidth = columnVisibility.visibleColumns.reduce((total, column) => total + column.minWidth, 0)
|
||||
|
||||
useEffect(() => {
|
||||
@ -72,6 +80,24 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
|
||||
setPageInput(String(nextPage))
|
||||
}
|
||||
|
||||
function toggleStatus(value: string) {
|
||||
setSelectedStatuses((current) => {
|
||||
if (value === 'ALL') return current.includes('ALL') ? [] : ['ALL']
|
||||
const withoutAll = current.filter((statusValue) => statusValue !== 'ALL')
|
||||
return withoutAll.includes(value)
|
||||
? withoutAll.filter((statusValue) => statusValue !== value)
|
||||
: [...withoutAll, value]
|
||||
})
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setKeyword('')
|
||||
setSelectedStatuses([])
|
||||
setOverdueOnly(false)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
async function remove(item: SampleProgressDTO) {
|
||||
if (!window.confirm(`确认删除 ${item.openingCode} 的打样记录?`)) return
|
||||
const response = await fetch(`/api/intake/sample-progress/${item.id}${scopeQuery}`, { method: 'DELETE' })
|
||||
@ -92,9 +118,9 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
|
||||
<Metric icon={<Link2 size={18}/>} label="OA 台账匹配" value={`${currentStageItems.length} / ${currentStageCodes.size}`} tone={currentStageItems.length === currentStageCodes.size ? 'success' : 'danger'} />
|
||||
</section>
|
||||
|
||||
<section className="grid shrink-0 grid-cols-3 border-b border-black/[0.06] bg-white md:grid-cols-5 xl:grid-cols-9">
|
||||
<section className="flex shrink-0 overflow-x-auto border-b border-black/[0.06] bg-white">
|
||||
{statusCounts.map(({ value, count }) => (
|
||||
<button key={value} type="button" disabled={editorItem !== null} onClick={() => { setStatus(value); setPage(1) }} className={`h-[68px] border-b border-r border-black/[0.05] px-4 text-left transition hover:bg-[#f7f9f8] disabled:cursor-not-allowed disabled:opacity-50 ${status === value ? 'bg-[#edf4f1]' : ''}`}>
|
||||
<button key={value} type="button" disabled={editorItem !== null} onClick={() => { setSelectedStatuses((current) => current.length === 1 && current[0] === value ? [] : [value]); setPage(1) }} className={`h-[68px] min-w-[124px] flex-1 border-b border-r border-black/[0.05] px-4 text-left transition hover:bg-[#f7f9f8] disabled:cursor-not-allowed disabled:opacity-50 ${selectedStatuses.length === 1 && selectedStatuses[0] === value ? 'bg-[#edf4f1]' : ''}`}>
|
||||
<div className="text-xs text-[#788083]">{SAMPLE_PROGRESS_STATUS_LABELS[value]}</div>
|
||||
<div className="mt-1 text-xl font-black text-[#222829]">{count}</div>
|
||||
</button>
|
||||
@ -102,17 +128,32 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col p-4 sm:p-6">
|
||||
<div className="mb-3 flex flex-col gap-2 md:flex-row">
|
||||
<label className="relative min-w-0 flex-1 md:max-w-[420px]"><Search className="absolute left-3 top-2.5 text-[#8c9496]" size={16}/><input disabled={editorItem !== null} value={keyword} onChange={(e) => { setKeyword(e.target.value); setPage(1) }} placeholder="搜索开款编号、品牌、供应商、设计师或采购负责人" className="h-10 w-full rounded-[6px] border border-black/[0.1] bg-white pl-9 pr-3 text-xs outline-none focus:border-[#26756b] disabled:cursor-not-allowed disabled:opacity-50"/></label>
|
||||
<select aria-label="打样状态" disabled={editorItem !== null} value={status} onChange={(e) => { setStatus(e.target.value); setPage(1) }} className="h-10 rounded-[6px] border border-black/[0.1] bg-white px-3 text-xs disabled:cursor-not-allowed disabled:opacity-50"><option value="">打样状态</option><option value="ALL">全部打样台账({items.length})</option>{SAMPLE_PROGRESS_STATUSES.map((value) => <option key={value} value={value}>{SAMPLE_PROGRESS_STATUS_LABELS[value]}</option>)}</select>
|
||||
{overdueOnly && <button type="button" disabled={editorItem !== null} onClick={() => setOverdueOnly(false)} className="h-10 rounded-[6px] border border-[#d76a63] bg-[#fff0ef] px-3 text-xs font-bold text-[#ad3c36] disabled:cursor-not-allowed disabled:opacity-50">交期超期 ×</button>}
|
||||
<SampleProgressColumnSettings
|
||||
hiddenCount={columnVisibility.hiddenCount}
|
||||
visibleColumnKeys={columnVisibility.visibleColumnKeySet}
|
||||
onToggleColumn={columnVisibility.toggleColumn}
|
||||
onShowAllColumns={columnVisibility.showAllColumns}
|
||||
/>
|
||||
{embedded && permissionCanOperate && <button disabled={editorItem !== null} onClick={() => setEditorItem('new')} className="inline-flex h-10 items-center justify-center gap-2 rounded-[6px] bg-[#222829] px-4 text-xs font-bold text-white hover:bg-black disabled:cursor-not-allowed disabled:opacity-40"><Plus size={16}/>新增打样</button>}
|
||||
<div className="mb-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<label className="relative min-w-0 flex-1 md:max-w-[420px]"><Search className="absolute left-3 top-2.5 text-[#8c9496]" size={16}/><input disabled={editorItem !== null} value={keyword} onChange={(e) => { setKeyword(e.target.value); setPage(1) }} placeholder="搜索开款编号、品牌、供应商、设计师或采购负责人" className="h-10 w-full rounded-[6px] border border-black/[0.1] bg-white pl-9 pr-3 text-xs outline-none focus:border-[#26756b] disabled:cursor-not-allowed disabled:opacity-50"/></label>
|
||||
<details className="relative shrink-0">
|
||||
<summary className="flex h-10 cursor-pointer list-none items-center rounded-[6px] border border-black/[0.1] bg-white px-3 text-xs font-bold text-[#334043]">打样状态{selectedStatuses.length > 0 && !selectedStatuses.includes('ALL') ? `(已选 ${selectedStatuses.length})` : ''}</summary>
|
||||
<fieldset aria-label="打样状态筛选" disabled={editorItem !== null} className="absolute left-0 top-11 z-40 flex max-h-[min(70vh,420px)] min-w-[230px] flex-col gap-2 overflow-y-auto rounded-[6px] border border-black/[0.1] bg-white p-3 text-xs shadow-[0_8px_20px_rgba(21,31,33,0.12)]">
|
||||
<legend className="sr-only">打样状态筛选</legend>
|
||||
<label className="flex items-center gap-2 whitespace-nowrap"><input type="checkbox" checked={selectedStatuses.includes('ALL')} onChange={() => toggleStatus('ALL')} />全部打样台账({items.length})</label>
|
||||
{SAMPLE_PROGRESS_STATUSES.map((value) => <label key={value} className="flex items-center gap-2 whitespace-nowrap"><input type="checkbox" checked={selectedStatuses.includes(value)} onChange={() => toggleStatus(value)} />{SAMPLE_PROGRESS_STATUS_LABELS[value]}</label>)}
|
||||
</fieldset>
|
||||
</details>
|
||||
{overdueOnly && <button type="button" disabled={editorItem !== null} onClick={() => setOverdueOnly(false)} className="h-10 shrink-0 rounded-[6px] border border-[#d76a63] bg-[#fff0ef] px-3 text-xs font-bold text-[#ad3c36] disabled:cursor-not-allowed disabled:opacity-50">交期超期 ×</button>}
|
||||
<button type="button" disabled={editorItem !== null} onClick={resetFilters} className="h-10 shrink-0 rounded-[6px] border border-black/[0.12] bg-white px-3 text-xs font-bold text-[#334043] hover:bg-[#f4f5f5] disabled:cursor-not-allowed disabled:opacity-50">筛选重置</button>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<SampleProgressColumnSettings
|
||||
hiddenCount={columnVisibility.hiddenCount}
|
||||
visibleColumnKeys={columnVisibility.visibleColumnKeySet}
|
||||
columnOrder={columnVisibility.columnOrder}
|
||||
onToggleColumn={columnVisibility.toggleColumn}
|
||||
onMoveColumn={columnVisibility.moveColumn}
|
||||
onShowAllColumns={columnVisibility.showAllColumns}
|
||||
onHideAllColumns={columnVisibility.hideAllColumns}
|
||||
/>
|
||||
{embedded && permissionCanOperate && <button disabled={editorItem !== null} onClick={() => setEditorItem('new')} className="inline-flex h-10 items-center justify-center gap-2 rounded-[6px] bg-[#222829] px-4 text-xs font-bold text-white hover:bg-black disabled:cursor-not-allowed disabled:opacity-40"><Plus size={16}/>新增打样</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-[6px] border border-black/[0.08] bg-white shadow-[0_8px_24px_rgba(21,31,33,0.05)]">
|
||||
@ -122,7 +163,7 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((item) => <SampleProgressTableRow key={item.id} item={item} visibleColumnKeys={columnVisibility.visibleColumnKeySet} overdue={isOverdue(item)} canEdit={canEdit(item)} canDelete={canDelete} onStartEdit={() => setEditorItem(item)} 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}/>) }
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length === 0 && <div className="py-16 text-center text-sm text-[#7d8588]">暂无匹配的打样记录</div>}
|
||||
@ -151,6 +192,14 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
|
||||
setEditorItem(null)
|
||||
}}
|
||||
/>}
|
||||
{viewItem && <SampleProgressEditorDrawer
|
||||
key={`view-${viewItem.id}`}
|
||||
item={viewItem}
|
||||
readOnly
|
||||
permissionScope={permissionScope}
|
||||
onClose={() => setViewItem(null)}
|
||||
onSaved={() => setViewItem(null)}
|
||||
/>}
|
||||
{historyItem && <ChangeHistory item={historyItem} permissionScope={permissionScope} onClose={() => setHistoryItem(null)}/>}
|
||||
{previewImage?.imageUrl && <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) setPreviewImage(null) }}><div className="relative h-[min(82vh,900px)] w-[min(90vw,1200px)]"><Image unoptimized src={previewImage.imageUrl} alt={previewImage.openingCode} fill sizes="90vw" className="object-contain"/><button type="button" title="关闭" onClick={() => setPreviewImage(null)} className="absolute right-0 top-0 flex h-10 w-10 items-center justify-center rounded-full bg-black/70 text-white"><X size={20}/></button><div className="absolute bottom-0 left-1/2 -translate-x-1/2 rounded-[4px] bg-black/70 px-3 py-2 text-sm font-bold text-white">{previewImage.openingCode}</div></div></div>}
|
||||
</main>
|
||||
|
||||
@ -1,23 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, LockKeyhole, RotateCcw, Settings2, X } from 'lucide-react'
|
||||
import { SAMPLE_PROGRESS_COLUMNS, type SampleProgressColumnKey } from '../sampleProgressColumns'
|
||||
import { Check, EyeOff, GripVertical, LockKeyhole, RotateCcw, Settings2, X } from 'lucide-react'
|
||||
import { getOrderedSampleProgressColumns, type SampleProgressColumnKey } from '../sampleProgressColumns'
|
||||
|
||||
type Props = {
|
||||
hiddenCount: number
|
||||
visibleColumnKeys: ReadonlySet<SampleProgressColumnKey>
|
||||
columnOrder: ReadonlyArray<SampleProgressColumnKey>
|
||||
onToggleColumn: (key: SampleProgressColumnKey) => void
|
||||
onMoveColumn: (sourceKey: SampleProgressColumnKey, targetKey: SampleProgressColumnKey) => void
|
||||
onShowAllColumns: () => void
|
||||
onHideAllColumns: () => void
|
||||
}
|
||||
|
||||
export default function SampleProgressColumnSettings({
|
||||
hiddenCount,
|
||||
visibleColumnKeys,
|
||||
columnOrder,
|
||||
onToggleColumn,
|
||||
onMoveColumn,
|
||||
onShowAllColumns,
|
||||
onHideAllColumns,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [draggingKey, setDraggingKey] = useState<SampleProgressColumnKey | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@ -55,13 +62,17 @@ export default function SampleProgressColumnSettings({
|
||||
<div className="flex items-center justify-between border-b border-black/[0.07] px-4 py-3">
|
||||
<div>
|
||||
<div className="text-sm font-black text-[#222829]">显示字段</div>
|
||||
<div className="mt-0.5 text-[11px] text-[#788083]">设置仅影响当前账号在本浏览器的表格</div>
|
||||
<div className="mt-0.5 text-[11px] text-[#788083]">拖动字段可调整顺序,仅影响当前账号在本浏览器的表格</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" disabled={hiddenCount === 0} onClick={onShowAllColumns} className="inline-flex h-8 items-center gap-1.5 rounded-[4px] px-2 text-xs font-bold text-[#26756b] hover:bg-[#edf4f1] disabled:cursor-not-allowed disabled:opacity-40">
|
||||
<button type="button" disabled={hiddenCount === 0} onClick={onShowAllColumns} className="inline-flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[4px] px-2 text-xs font-bold text-[#26756b] hover:bg-[#edf4f1] disabled:cursor-not-allowed disabled:opacity-40">
|
||||
<RotateCcw size={13}/>
|
||||
全部显示
|
||||
</button>
|
||||
<button type="button" disabled={hiddenCount === columnOrder.length} onClick={onHideAllColumns} className="inline-flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[4px] px-2 text-xs font-bold text-[#657074] hover:bg-[#f3f6f5] disabled:cursor-not-allowed disabled:opacity-40">
|
||||
<EyeOff size={13}/>
|
||||
全部隐藏
|
||||
</button>
|
||||
<button type="button" title="关闭列设置" aria-label="关闭列设置" onClick={() => setOpen(false)} className="inline-flex h-8 w-8 items-center justify-center rounded-[4px] text-[#657074] hover:bg-black/[0.05]">
|
||||
<X size={15}/>
|
||||
</button>
|
||||
@ -69,16 +80,37 @@ export default function SampleProgressColumnSettings({
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 gap-x-3 gap-y-1 overflow-y-auto p-3">
|
||||
{SAMPLE_PROGRESS_COLUMNS.map((column) => {
|
||||
{getOrderedSampleProgressColumns(columnOrder).map((column) => {
|
||||
const checked = visibleColumnKeys.has(column.key)
|
||||
return <label key={column.key} className={`relative flex min-h-9 items-center gap-2 rounded-[4px] px-2 text-xs ${column.locked ? 'cursor-not-allowed text-[#8b9294]' : 'cursor-pointer text-[#343d3f] hover:bg-[#f3f6f5]'}`}>
|
||||
return <label
|
||||
key={column.key}
|
||||
draggable={!column.locked}
|
||||
onDragStart={(event) => {
|
||||
if (column.locked) return
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', column.key)
|
||||
setDraggingKey(column.key)
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!column.locked && draggingKey && draggingKey !== column.key) event.preventDefault()
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
const sourceKey = (event.dataTransfer.getData('text/plain') || draggingKey) as SampleProgressColumnKey
|
||||
if (!column.locked && sourceKey) onMoveColumn(sourceKey, column.key)
|
||||
setDraggingKey(null)
|
||||
}}
|
||||
onDragEnd={() => setDraggingKey(null)}
|
||||
className={`relative flex min-h-9 items-center gap-2 rounded-[4px] px-2 text-xs ${column.locked ? 'cursor-not-allowed text-[#8b9294]' : 'cursor-grab text-[#343d3f] hover:bg-[#f3f6f5] active:cursor-grabbing'} ${draggingKey === column.key ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={column.locked}
|
||||
onChange={() => onToggleColumn(column.key)}
|
||||
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
||||
className="pointer-events-none absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<GripVertical aria-hidden="true" size={14} className={column.locked ? 'text-[#c0c5c6]' : 'text-[#9ca6a8]'}/>
|
||||
<span aria-hidden="true" className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-[3px] border ${checked ? column.locked ? 'border-[#c7cccd] bg-[#c7cccd] text-white' : 'border-[#26756b] bg-[#26756b] text-white' : 'border-[#aeb5b7] bg-white'}`}>
|
||||
{checked && <Check size={12} strokeWidth={3}/>}
|
||||
</span>
|
||||
|
||||
@ -59,9 +59,19 @@ describe('SampleProgressEditorDrawer', () => {
|
||||
expect(screen.getByLabelText('开款编号')).toBeDisabled()
|
||||
expect(screen.getByLabelText('产品尺寸')).toHaveValue('1200x600x450')
|
||||
expect(screen.getByLabelText('供应商')).toHaveValue('测试供应商')
|
||||
expect(screen.queryByLabelText('供应商确认时间')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('设计师甲')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens a read-only view without edit controls', () => {
|
||||
render(<SampleProgressEditorDrawer item={item} readOnly onClose={jest.fn()} onSaved={jest.fn()}/>)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '查看打样记录' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('颜色')).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新增供应商' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('separates brands and opens the product image preview', () => {
|
||||
const visualItem = {
|
||||
...item,
|
||||
|
||||
@ -66,12 +66,13 @@ const inputDate = (value: string | Date) => {
|
||||
}
|
||||
type Props = {
|
||||
item: SampleProgressDTO | null
|
||||
readOnly?: boolean
|
||||
permissionScope?: 'supply-chain'
|
||||
onClose: () => void
|
||||
onSaved: (item: SampleProgressDTO) => void
|
||||
}
|
||||
|
||||
export default function SampleProgressEditorDrawer({ item, permissionScope, onClose, onSaved }: Props) {
|
||||
export default function SampleProgressEditorDrawer({ item, readOnly = false, permissionScope, onClose, onSaved }: Props) {
|
||||
const titleId = useId()
|
||||
const [draft, setDraft] = useState<SampleProgressDraft>(() => item ? draftFromItem(item) : emptyDraft())
|
||||
const [oaReachedAt, setOaReachedAt] = useState<string | null>(item?.oaReachedAt ?? null)
|
||||
@ -119,6 +120,7 @@ export default function SampleProgressEditorDrawer({ item, permissionScope, onCl
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (readOnly) return
|
||||
if (!draft.openingCode.trim()) { setMessage('请填写开款编号'); return }
|
||||
const missingReasonIndex = draft.suppliers.findIndex((supplier, index) => dueDateChanged(supplier, index) && !supplier.deliveryDateChangeReason?.trim())
|
||||
if (missingReasonIndex >= 0) { setMessage(`请填写供应商 ${String.fromCharCode(65 + missingReasonIndex)} 的交期变更原因`); return }
|
||||
@ -158,7 +160,7 @@ export default function SampleProgressEditorDrawer({ item, permissionScope, onCl
|
||||
return <div className="fixed inset-0 z-[90] flex justify-end bg-black/25" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) onClose() }}>
|
||||
<aside role="dialog" aria-modal="true" aria-labelledby={titleId} className="flex h-full w-full max-w-[1180px] flex-col bg-white shadow-2xl">
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-black/[0.08] px-5 py-4">
|
||||
<div><h2 id={titleId} className="text-lg font-black">{item ? '编辑打样记录' : '新增打样记录'}</h2><p className="mt-1 text-xs text-[#788083]">{item?.openingCode || '填写开款编号后读取 OA 资料'}</p></div>
|
||||
<div><h2 id={titleId} className="text-lg font-black">{readOnly ? '查看打样记录' : item ? '编辑打样记录' : '新增打样记录'}</h2><p className="mt-1 text-xs text-[#788083]">{item?.openingCode || '填写开款编号后读取 OA 资料'}</p></div>
|
||||
<button type="button" title="关闭" aria-label="关闭编辑窗口" disabled={saving} onClick={onClose} className="inline-flex h-9 w-9 items-center justify-center rounded-[4px] hover:bg-black/[0.05] disabled:opacity-40"><X size={19}/></button>
|
||||
</header>
|
||||
|
||||
@ -174,7 +176,7 @@ export default function SampleProgressEditorDrawer({ item, permissionScope, onCl
|
||||
</div>
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className={labelClass}>开款编号<input aria-label="开款编号" disabled={Boolean(item)} value={draft.openingCode} onChange={(event) => setField('openingCode', event.target.value)} onBlur={() => void loadOaPrefill()} className={inputClass}/></label>
|
||||
<label className={labelClass}>开款编号<input aria-label="开款编号" disabled={Boolean(item) || readOnly} value={draft.openingCode} onChange={(event) => setField('openingCode', event.target.value)} onBlur={() => void loadOaPrefill()} className={inputClass}/></label>
|
||||
<ReadOnlyField label="收到流程时间" value={oaReachedAt ? new Date(oaReachedAt).toLocaleString('zh-CN') : '—'}/>
|
||||
</div>
|
||||
<BrandReadOnlyField value={draft.brand}/>
|
||||
@ -188,16 +190,16 @@ export default function SampleProgressEditorDrawer({ item, permissionScope, onCl
|
||||
</EditorSection>
|
||||
|
||||
<EditorSection title="产品资料"><div className="grid gap-4 sm:grid-cols-2">
|
||||
<TextField label="产品尺寸" value={draft.productSize} onChange={(value) => setField('productSize', value)}/>
|
||||
<TextField label="颜色" value={draft.color} onChange={(value) => setField('color', value)}/>
|
||||
<TextField label="材质" value={draft.material} onChange={(value) => setField('material', value)}/>
|
||||
<label className={labelClass}>打样数量<input aria-label="打样数量" min="1" type="number" value={draft.sampleQuantity ?? ''} onChange={(event) => setField('sampleQuantity', event.target.value ? Number(event.target.value) : null)} className={inputClass}/></label>
|
||||
<TextField label="产品尺寸" value={draft.productSize} disabled={readOnly} onChange={(value) => setField('productSize', value)}/>
|
||||
<TextField label="颜色" value={draft.color} disabled={readOnly} onChange={(value) => setField('color', value)}/>
|
||||
<TextField label="材质" value={draft.material} disabled={readOnly} onChange={(value) => setField('material', value)}/>
|
||||
<label className={labelClass}>打样数量<input aria-label="打样数量" disabled={readOnly} min="1" type="number" value={draft.sampleQuantity ?? ''} onChange={(event) => setField('sampleQuantity', event.target.value ? Number(event.target.value) : null)} className={inputClass}/></label>
|
||||
</div></EditorSection>
|
||||
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between gap-3 border-b border-black/[0.07] pb-2">
|
||||
<h3 className="text-sm font-black text-[#252b2d]">供应商与打样进度</h3>
|
||||
<button type="button" onClick={() => setField('suppliers', [...draft.suppliers, emptySupplier(draft.suppliers[0]?.sampleDueDate || new Date())])} className="inline-flex h-8 items-center gap-1.5 rounded-[4px] border border-black/[0.12] bg-white px-3 text-xs font-bold text-[#334043] hover:bg-[#f4f5f5]"><Plus size={14}/>新增供应商</button>
|
||||
{!readOnly && <button type="button" onClick={() => setField('suppliers', [...draft.suppliers, emptySupplier(draft.suppliers[0]?.sampleDueDate || new Date())])} className="inline-flex h-8 items-center gap-1.5 rounded-[4px] border border-black/[0.12] bg-white px-3 text-xs font-bold text-[#334043] hover:bg-[#f4f5f5]"><Plus size={14}/>新增供应商</button>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 overflow-x-auto md:flex-row md:gap-0">
|
||||
{draft.suppliers.map((supplier, index) => <SampleProgressSupplierEditor
|
||||
@ -208,20 +210,21 @@ export default function SampleProgressEditorDrawer({ item, permissionScope, onCl
|
||||
canRemove={draft.suppliers.length > 1}
|
||||
onChange={(nextValue) => setField('suppliers', draft.suppliers.map((current, currentIndex) => currentIndex === index ? nextValue : current))}
|
||||
onRemove={() => setField('suppliers', draft.suppliers.filter((_, currentIndex) => currentIndex !== index))}
|
||||
readOnly={readOnly}
|
||||
/>)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EditorSection title="相关链接"><label className={labelClass}>由壹点链接<input aria-label="由壹点链接" type="url" maxLength={8000} placeholder="https://" value={draft.productDevelopmentFlowUrl || ''} onChange={(event) => setField('productDevelopmentFlowUrl', event.target.value)} className={inputClass}/></label></EditorSection>
|
||||
<EditorSection title="相关链接"><label className={labelClass}>由壹点链接<input aria-label="由壹点链接" disabled={readOnly} type="url" maxLength={8000} placeholder="https://" value={draft.productDevelopmentFlowUrl || ''} onChange={(event) => setField('productDevelopmentFlowUrl', event.target.value)} className={inputClass}/>{readOnly && draft.productDevelopmentFlowUrl && <a href={draft.productDevelopmentFlowUrl} target="_blank" rel="noreferrer" className="mt-2 inline-flex text-xs font-bold text-[#26756b] hover:underline">Open link</a>}</label></EditorSection>
|
||||
</div></div>
|
||||
|
||||
<footer className="flex shrink-0 items-center justify-between gap-4 border-t border-black/[0.08] bg-white px-5 py-4">
|
||||
{!readOnly && <footer className="flex shrink-0 items-center justify-between gap-4 border-t border-black/[0.08] bg-white px-5 py-4">
|
||||
<div role={message ? 'alert' : undefined} className="min-w-0 text-xs font-semibold text-[#b8423b]">{message}</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button type="button" disabled={saving} onClick={onClose} className="h-10 rounded-[5px] border border-black/[0.12] bg-white px-4 text-sm font-bold hover:bg-[#f4f5f5] disabled:opacity-40">取消</button>
|
||||
<button type="button" disabled={saving} onClick={() => void save()} className="inline-flex h-10 min-w-[108px] items-center justify-center gap-2 rounded-[5px] bg-[#222829] px-4 text-sm font-bold text-white hover:bg-black disabled:cursor-wait disabled:opacity-60">{saving ? <LoaderCircle size={16} className="animate-spin"/> : <Save size={16}/>} {saving ? '保存中' : '保存'}</button>
|
||||
</div>
|
||||
</footer>
|
||||
</footer>}
|
||||
</aside>
|
||||
{previewOpen && draft.imageUrl && <div role="dialog" aria-modal="true" aria-label="产品大图预览" className="fixed inset-0 z-[120] flex items-center justify-center bg-black/85 p-4 sm:p-8" onMouseDown={(event) => { if (event.target === event.currentTarget) setPreviewOpen(false) }}>
|
||||
<div className="relative h-full max-h-[900px] w-full max-w-[1200px]">
|
||||
@ -235,8 +238,8 @@ export default function SampleProgressEditorDrawer({ item, permissionScope, onCl
|
||||
function EditorSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return <section><h3 className="mb-3 border-b border-black/[0.07] pb-2 text-sm font-black text-[#252b2d]">{title}</h3>{children}</section>
|
||||
}
|
||||
function TextField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return <label className={labelClass}>{label}<input aria-label={label} value={value} onChange={(event) => onChange(event.target.value)} className={inputClass}/></label>
|
||||
function TextField({ label, value, disabled = false, onChange }: { label: string; value: string; disabled?: boolean; onChange: (value: string) => void }) {
|
||||
return <label className={labelClass}>{label}<input aria-label={label} disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)} className={inputClass}/></label>
|
||||
}
|
||||
function ReadOnlyField({ label, value }: { label: string; value: string }) {
|
||||
return <div className={`${labelClass} min-w-0`}><span>{label}</span><div className="flex min-h-10 items-center break-words rounded-[5px] border border-black/[0.07] bg-[#f4f5f5] px-3 py-2 text-sm font-medium text-[#4f595c]">{value}</div></div>
|
||||
|
||||
@ -30,11 +30,12 @@ type Props = {
|
||||
value: SampleProgressSupplierInput
|
||||
dueDateChanged: boolean
|
||||
canRemove: boolean
|
||||
readOnly?: boolean
|
||||
onChange: (value: SampleProgressSupplierInput) => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export default function SampleProgressSupplierEditor({ index, value, dueDateChanged, canRemove, onChange, onRemove }: Props) {
|
||||
export default function SampleProgressSupplierEditor({ index, value, dueDateChanged, canRemove, readOnly = false, onChange, onRemove }: Props) {
|
||||
const setField = <K extends keyof SampleProgressSupplierInput>(key: K, nextValue: SampleProgressSupplierInput[K]) => {
|
||||
onChange({ ...value, [key]: nextValue })
|
||||
}
|
||||
@ -43,31 +44,30 @@ export default function SampleProgressSupplierEditor({ index, value, dueDateChan
|
||||
return <section className="w-full shrink-0 border-t border-black/[0.08] pt-3 first:border-t-0 first:pt-0 md:min-w-[260px] md:flex-1 md:border-l md:border-t-0 md:px-4 md:pt-0 md:first:border-l-0 md:first:pl-0 md:last:pr-0">
|
||||
<header className="mb-3 flex h-8 items-center justify-between gap-2">
|
||||
<h4 className="text-xs font-black text-[#252b2d]">供应商 {String.fromCharCode(65 + index)}</h4>
|
||||
{canRemove && <button type="button" title={`删除供应商 ${String.fromCharCode(65 + index)}`} aria-label={`删除供应商 ${String.fromCharCode(65 + index)}`} onClick={onRemove} className="inline-flex h-8 w-8 items-center justify-center rounded-[4px] text-[#9a4a45] hover:bg-[#fff0ef]"><Trash2 size={15}/></button>}
|
||||
{canRemove && !readOnly && <button type="button" title={`删除供应商 ${String.fromCharCode(65 + index)}`} aria-label={`删除供应商 ${String.fromCharCode(65 + index)}`} onClick={onRemove} className="inline-flex h-8 w-8 items-center justify-center rounded-[4px] text-[#9a4a45] hover:bg-[#fff0ef]"><Trash2 size={15}/></button>}
|
||||
</header>
|
||||
<div className="space-y-3">
|
||||
<TextField label="供应商" ariaLabel={fieldLabel('供应商')} value={value.supplier} onChange={(nextValue) => setField('supplier', nextValue)}/>
|
||||
<DateTimeField label="供应商确认时间" ariaLabel={fieldLabel('供应商确认时间')} value={value.supplierConfirmedAt} onChange={(nextValue) => setField('supplierConfirmedAt', nextValue)}/>
|
||||
<DateTimeField label="通知打样时间" ariaLabel={fieldLabel('通知打样时间')} value={value.sampleNotifiedAt} onChange={(nextValue) => setField('sampleNotifiedAt', nextValue)}/>
|
||||
<label className={labelClass}>打样交期<input aria-label={fieldLabel('打样交期')} type="date" value={inputDate(value.sampleDueDate)} onChange={(event) => { if (event.target.value) setField('sampleDueDate', parseDateInput(event.target.value)) }} className={inputClass}/></label>
|
||||
{dueDateChanged && <TextField label="交期变更原因" ariaLabel={fieldLabel('交期变更原因')} value={value.deliveryDateChangeReason || ''} onChange={(nextValue) => setField('deliveryDateChangeReason', nextValue)}/>}
|
||||
<TextField label="寄出快递单号" ariaLabel={fieldLabel('寄出快递单号')} value={value.courierTrackingNumber || ''} onChange={(nextValue) => setField('courierTrackingNumber', nextValue)}/>
|
||||
<label className={labelClass}>打样状态<select aria-label={fieldLabel('打样状态')} value={value.status} onChange={(event) => setField('status', event.target.value as SampleProgressSupplierInput['status'])} className={inputClass}>{SAMPLE_PROGRESS_STATUSES.map((status) => <option key={status} value={status}>{SAMPLE_PROGRESS_STATUS_LABELS[status]}</option>)}</select></label>
|
||||
<MoneyField label="需求成本" ariaLabel={fieldLabel('需求成本')} value={value.requestedCost} onChange={(nextValue) => setField('requestedCost', nextValue)}/>
|
||||
<MoneyField label="实际成本" ariaLabel={fieldLabel('实际成本')} value={value.actualCost} onChange={(nextValue) => setField('actualCost', nextValue)}/>
|
||||
<label className={labelClass}>进度说明<textarea aria-label={fieldLabel('进度说明')} rows={4} value={value.progressDescription || ''} onChange={(event) => setField('progressDescription', event.target.value)} className={`${inputClass} h-auto min-h-[92px] resize-y py-2.5`}/></label>
|
||||
<TextField label="供应商" ariaLabel={fieldLabel('供应商')} disabled={readOnly} value={value.supplier} onChange={(nextValue) => setField('supplier', nextValue)}/>
|
||||
<DateTimeField label="通知打样时间" ariaLabel={fieldLabel('通知打样时间')} disabled={readOnly} value={value.sampleNotifiedAt} onChange={(nextValue) => setField('sampleNotifiedAt', nextValue)}/>
|
||||
<label className={labelClass}>打样交期<input aria-label={fieldLabel('打样交期')} disabled={readOnly} type="date" value={inputDate(value.sampleDueDate)} onChange={(event) => { if (event.target.value) setField('sampleDueDate', parseDateInput(event.target.value)) }} className={inputClass}/></label>
|
||||
{dueDateChanged && <TextField label="交期变更原因" ariaLabel={fieldLabel('交期变更原因')} disabled={readOnly} value={value.deliveryDateChangeReason || ''} onChange={(nextValue) => setField('deliveryDateChangeReason', nextValue)}/>}
|
||||
<TextField label="寄出快递单号" ariaLabel={fieldLabel('寄出快递单号')} disabled={readOnly} value={value.courierTrackingNumber || ''} onChange={(nextValue) => setField('courierTrackingNumber', nextValue)}/>
|
||||
<label className={labelClass}>打样状态<select aria-label={fieldLabel('打样状态')} disabled={readOnly} value={value.status} onChange={(event) => setField('status', event.target.value as SampleProgressSupplierInput['status'])} className={inputClass}>{SAMPLE_PROGRESS_STATUSES.map((status) => <option key={status} value={status}>{SAMPLE_PROGRESS_STATUS_LABELS[status]}</option>)}</select></label>
|
||||
<MoneyField label="需求成本" ariaLabel={fieldLabel('需求成本')} disabled={readOnly} value={value.requestedCost} onChange={(nextValue) => setField('requestedCost', nextValue)}/>
|
||||
<MoneyField label="实际成本" ariaLabel={fieldLabel('实际成本')} disabled={readOnly} value={value.actualCost} onChange={(nextValue) => setField('actualCost', nextValue)}/>
|
||||
<label className={labelClass}>进度说明<textarea aria-label={fieldLabel('进度说明')} disabled={readOnly} rows={4} value={value.progressDescription || ''} onChange={(event) => setField('progressDescription', event.target.value)} className={`${inputClass} h-auto min-h-[92px] resize-y py-2.5`}/></label>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
function TextField({ label, ariaLabel, value, onChange }: { label: string; ariaLabel: string; value: string; onChange: (value: string) => void }) {
|
||||
return <label className={labelClass}>{label}<input aria-label={ariaLabel} value={value} onChange={(event) => onChange(event.target.value)} className={inputClass}/></label>
|
||||
function TextField({ label, ariaLabel, value, disabled = false, onChange }: { label: string; ariaLabel: string; value: string; disabled?: boolean; onChange: (value: string) => void }) {
|
||||
return <label className={labelClass}>{label}<input aria-label={ariaLabel} disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)} className={inputClass}/></label>
|
||||
}
|
||||
|
||||
function DateTimeField({ label, ariaLabel, value, onChange }: { label: string; ariaLabel: string; value: Date | null | undefined; onChange: (value: Date | null) => void }) {
|
||||
return <label className={labelClass}>{label}<input aria-label={ariaLabel} type="datetime-local" value={inputDateTime(value)} onChange={(event) => onChange(event.target.value ? new Date(event.target.value) : null)} className={inputClass}/></label>
|
||||
function DateTimeField({ label, ariaLabel, value, disabled = false, onChange }: { label: string; ariaLabel: string; value: Date | null | undefined; disabled?: boolean; onChange: (value: Date | null) => void }) {
|
||||
return <label className={labelClass}>{label}<input aria-label={ariaLabel} disabled={disabled} type="datetime-local" value={inputDateTime(value)} onChange={(event) => onChange(event.target.value ? new Date(event.target.value) : null)} className={inputClass}/></label>
|
||||
}
|
||||
|
||||
function MoneyField({ label, ariaLabel, value, onChange }: { label: string; ariaLabel: string; value: number | null | undefined; onChange: (value: number | null) => void }) {
|
||||
return <label className={labelClass}>{label}<input aria-label={ariaLabel} min="0" step="0.01" type="number" value={value ?? ''} onChange={(event) => onChange(event.target.value ? Number(event.target.value) : null)} className={inputClass}/></label>
|
||||
function MoneyField({ label, ariaLabel, value, disabled = false, onChange }: { label: string; ariaLabel: string; value: number | null | undefined; disabled?: boolean; onChange: (value: number | null) => void }) {
|
||||
return <label className={labelClass}>{label}<input aria-label={ariaLabel} disabled={disabled} min="0" step="0.01" type="number" value={value ?? ''} onChange={(event) => onChange(event.target.value ? Number(event.target.value) : null)} className={inputClass}/></label>
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Edit3, ExternalLink, History, ImageIcon, Trash2, ZoomIn } from 'lucide-react'
|
||||
import { Edit3, ExternalLink, Eye, History, ImageIcon, Trash2, ZoomIn } from 'lucide-react'
|
||||
import { calculateSampleProgressDurationMs, SAMPLE_PROGRESS_STATUS_LABELS } from '@/lib/sample-progress'
|
||||
import type { SampleProgressDTO } from '@/server/services/sample-progress.service'
|
||||
import type { SampleProgressColumnKey } from '../sampleProgressColumns'
|
||||
@ -36,12 +36,13 @@ type Props = {
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
onStartEdit: () => void
|
||||
onView: (item: SampleProgressDTO) => void
|
||||
onHistory: (item: SampleProgressDTO) => void
|
||||
onDelete: (item: SampleProgressDTO) => void
|
||||
onPreview: (item: SampleProgressDTO) => void
|
||||
}
|
||||
|
||||
export default function SampleProgressTableRow({ item, visibleColumnKeys, overdue, canEdit, canDelete, onStartEdit, onHistory, onDelete, onPreview }: Props) {
|
||||
export default function SampleProgressTableRow({ item, visibleColumnKeys, overdue, canEdit, canDelete, onStartEdit, onView, onHistory, onDelete, onPreview }: Props) {
|
||||
const actualCompletedAt = item.actualSampleCompletedAt ?? null
|
||||
const reviewHref = buildSampleProgressReviewHref(item.productDevelopmentFlowUrl)
|
||||
const visible = (key: SampleProgressColumnKey) => visibleColumnKeys.has(key)
|
||||
@ -66,12 +67,10 @@ export default function SampleProgressTableRow({ item, visibleColumnKeys, overdu
|
||||
return <span className={supplierOverdue ? 'text-[#c53f38]' : ''}>{dateOnlyText(supplier.sampleDueDate)}{supplierOverdue && <span className="ml-1 inline-flex rounded-[3px] bg-[#ffe1df] px-1.5 py-0.5 text-[10px] text-[#ad302a]">超期</span>}</span>
|
||||
}}</SupplierStack></td>}
|
||||
{visible('status') && <td data-column-key="status" className="px-2"><SupplierStack suppliers={suppliers}>{(supplier) => <Status status={supplier.status}/>}</SupplierStack></td>}
|
||||
{visible('productSize') && <td data-column-key="productSize" className="px-2">{item.productSize || '—'}</td>}
|
||||
{visible('color') && <td data-column-key="color" className="min-w-[100px] whitespace-nowrap px-2">{item.color || '—'}</td>}
|
||||
{visible('productSpec') && <td data-column-key="productSpec" className="min-w-[180px] whitespace-nowrap px-2">{productSpecText(item.productSize, item.color)}</td>}
|
||||
{visible('material') && <td data-column-key="material" className="min-w-[120px] whitespace-nowrap px-2">{item.material || '—'}</td>}
|
||||
{visible('sampleQuantity') && <td data-column-key="sampleQuantity" className="px-2 font-bold">{item.sampleQuantity ?? '—'}</td>}
|
||||
{visible('supplier') && <td data-column-key="supplier" className="min-w-[160px] px-2"><SupplierStack suppliers={suppliers}>{(supplier) => supplier.supplier || '—'}</SupplierStack></td>}
|
||||
{visible('supplierConfirmedAt') && <td data-column-key="supplierConfirmedAt" className="whitespace-nowrap px-2"><SupplierStack suppliers={suppliers}>{(supplier) => supplier.supplierConfirmedAt ? timestampDateText(supplier.supplierConfirmedAt) : '—'}</SupplierStack></td>}
|
||||
{visible('sampleNotifiedAt') && <td data-column-key="sampleNotifiedAt" className="whitespace-nowrap px-2"><SupplierStack suppliers={suppliers}>{(supplier) => supplier.sampleNotifiedAt ? timestampDateText(supplier.sampleNotifiedAt) : '—'}</SupplierStack></td>}
|
||||
{visible('courierTrackingNumber') && <td data-column-key="courierTrackingNumber" className="px-2"><SupplierStack suppliers={suppliers}>{(supplier) => supplier.courierTrackingNumber || '—'}</SupplierStack></td>}
|
||||
{visible('progressDescription') && <td data-column-key="progressDescription" className="max-w-[260px] px-2"><SupplierStack suppliers={suppliers}>{(supplier) => <div className="line-clamp-2" title={supplier.progressDescription || ''}>{supplier.progressDescription || '—'}</div>}</SupplierStack></td>}
|
||||
@ -79,7 +78,7 @@ export default function SampleProgressTableRow({ item, visibleColumnKeys, overdu
|
||||
{visible('actualCost') && <td data-column-key="actualCost" className="px-2 font-bold"><SupplierStack suppliers={suppliers}>{(supplier) => <span className={supplier.actualCost !== null && supplier.requestedCost !== null && supplier.actualCost > supplier.requestedCost ? 'text-[#b8423b]' : ''}>{money(supplier.actualCost)}</span>}</SupplierStack></td>}
|
||||
{visible('reviewForm') && <td data-column-key="reviewForm" className="px-3">{reviewHref ? <a href={reviewHref} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 whitespace-nowrap font-bold text-[#26756b] hover:underline">查看样品评审<ExternalLink size={13}/></a> : '—'}</td>}
|
||||
{visible('productDevelopmentFlowUrl') && <td data-column-key="productDevelopmentFlowUrl" className="px-2">{item.productDevelopmentFlowUrl ? <a href={item.productDevelopmentFlowUrl} target="_blank" rel="noreferrer" title={item.productDevelopmentFlowUrl} className="inline-flex items-center gap-1 whitespace-nowrap font-bold text-[#26756b] hover:underline">打开链接<ExternalLink size={13}/></a> : '—'}</td>}
|
||||
{visible('actions') && <td data-column-key="actions" className="sticky right-0 z-10 border-l border-black/[0.06] bg-white px-3 shadow-[-8px_0_16px_rgba(21,31,33,0.04)] group-hover:bg-[#f8faf9]"><div className="flex justify-center gap-1"><button type="button" title="操作日志" onClick={() => onHistory(item)} className="rounded-[4px] p-2 hover:bg-black/[0.05]"><History size={15}/></button>{canEdit && <button type="button" title="编辑" onClick={onStartEdit} className="rounded-[4px] p-2 hover:bg-black/[0.05]"><Edit3 size={15}/></button>}{canDelete && <button type="button" title="删除" onClick={() => onDelete(item)} className="rounded-[4px] p-2 text-[#b8423b] hover:bg-[#fff0ef]"><Trash2 size={15}/></button>}</div></td>}
|
||||
{visible('actions') && <td data-column-key="actions" className="sticky right-0 z-10 border-l border-black/[0.06] bg-white px-3 shadow-[-8px_0_16px_rgba(21,31,33,0.04)] group-hover:bg-[#f8faf9]"><div className="flex justify-center gap-1 whitespace-nowrap"><button type="button" title="View details" aria-label="View details" onClick={() => onView(item)} className="rounded-[4px] p-2 hover:bg-black/[0.05]"><Eye size={15}/></button><button type="button" title="View change history" aria-label="View change history" onClick={() => onHistory(item)} className="rounded-[4px] p-2 hover:bg-black/[0.05]"><History size={15}/></button>{canEdit && <button type="button" title="Edit" aria-label="Edit" onClick={onStartEdit} className="rounded-[4px] p-2 hover:bg-black/[0.05]"><Edit3 size={15}/></button>}{canDelete && <button type="button" title="Delete" aria-label="Delete" onClick={() => onDelete(item)} className="rounded-[4px] p-2 text-[#b8423b] hover:bg-[#fff0ef]"><Trash2 size={15}/></button>}</div></td>}
|
||||
</tr>
|
||||
}
|
||||
|
||||
@ -98,3 +97,8 @@ function BrandList({ value }: { value: string }) {
|
||||
if (brands.length === 0) return <span>—</span>
|
||||
return <div className="max-h-[60px] overflow-y-auto overscroll-contain pr-2 [scrollbar-width:thin]" title={brands.join('\n')}>{brands.map((brand, index) => <div key={`${brand}-${index}`} className="whitespace-nowrap leading-5">{brand}</div>)}</div>
|
||||
}
|
||||
|
||||
function productSpecText(productSize: string, color: string) {
|
||||
const parts = [productSize.trim(), color.trim()].filter(Boolean)
|
||||
return parts.length > 0 ? parts.join(' ') : '—'
|
||||
}
|
||||
|
||||
@ -12,12 +12,10 @@ export type SampleProgressColumnKey =
|
||||
| 'sampleDuration'
|
||||
| 'sampleDueDate'
|
||||
| 'status'
|
||||
| 'productSize'
|
||||
| 'color'
|
||||
| 'productSpec'
|
||||
| 'material'
|
||||
| 'sampleQuantity'
|
||||
| 'supplier'
|
||||
| 'supplierConfirmedAt'
|
||||
| 'sampleNotifiedAt'
|
||||
| 'courierTrackingNumber'
|
||||
| 'progressDescription'
|
||||
@ -49,12 +47,10 @@ export const SAMPLE_PROGRESS_COLUMNS: readonly SampleProgressColumn[] = [
|
||||
{ key: 'sampleDuration', label: '打样时间', minWidth: 110 },
|
||||
{ key: 'sampleDueDate', label: '打样交期', minWidth: 110 },
|
||||
{ key: 'status', label: '打样状态', minWidth: 120 },
|
||||
{ key: 'productSize', label: '产品尺寸', minWidth: 120 },
|
||||
{ key: 'color', label: '颜色', minWidth: 100 },
|
||||
{ key: 'productSpec', label: '产品规格', minWidth: 180 },
|
||||
{ key: 'material', label: '材质', minWidth: 120 },
|
||||
{ key: 'sampleQuantity', label: '打样数量', minWidth: 90 },
|
||||
{ key: 'supplier', label: '供应商', minWidth: 160 },
|
||||
{ key: 'supplierConfirmedAt', label: '供应商确认时间', minWidth: 150 },
|
||||
{ key: 'sampleNotifiedAt', label: '通知打样时间', minWidth: 126 },
|
||||
{ key: 'courierTrackingNumber', label: '寄出快递单号', minWidth: 150 },
|
||||
{ key: 'progressDescription', label: '进度说明', minWidth: 220 },
|
||||
@ -62,7 +58,7 @@ export const SAMPLE_PROGRESS_COLUMNS: readonly SampleProgressColumn[] = [
|
||||
{ key: 'actualCost', label: '实际成本', minWidth: 100 },
|
||||
{ key: 'reviewForm', label: '评审单', minWidth: 140 },
|
||||
{ key: 'productDevelopmentFlowUrl', label: '由壹点链接', minWidth: 120 },
|
||||
{ key: 'actions', label: '操作', minWidth: 124, locked: true, headerClassName: 'right-0 z-40 border-l border-black/[0.07]' },
|
||||
{ key: 'actions', label: '操作', minWidth: 160, locked: true, headerClassName: 'right-0 z-40 border-l border-black/[0.07]' },
|
||||
]
|
||||
|
||||
export const SAMPLE_PROGRESS_COLUMN_KEYS = new Set<SampleProgressColumnKey>(
|
||||
@ -73,6 +69,44 @@ export const LOCKED_SAMPLE_PROGRESS_COLUMN_KEYS = new Set<SampleProgressColumnKe
|
||||
SAMPLE_PROGRESS_COLUMNS.filter((column) => column.locked).map((column) => column.key),
|
||||
)
|
||||
|
||||
export function getVisibleSampleProgressColumns(hiddenKeys: ReadonlySet<SampleProgressColumnKey>) {
|
||||
return SAMPLE_PROGRESS_COLUMNS.filter((column) => column.locked || !hiddenKeys.has(column.key))
|
||||
export const DEFAULT_SAMPLE_PROGRESS_COLUMN_ORDER = SAMPLE_PROGRESS_COLUMNS
|
||||
.filter((column) => !column.locked)
|
||||
.map((column) => column.key)
|
||||
|
||||
export function normalizeSampleProgressColumnOrder(value: unknown): SampleProgressColumnKey[] {
|
||||
if (!Array.isArray(value)) return [...DEFAULT_SAMPLE_PROGRESS_COLUMN_ORDER]
|
||||
|
||||
const normalized = new Set<SampleProgressColumnKey>()
|
||||
for (const key of value) {
|
||||
if (
|
||||
typeof key === 'string'
|
||||
&& SAMPLE_PROGRESS_COLUMN_KEYS.has(key as SampleProgressColumnKey)
|
||||
&& !LOCKED_SAMPLE_PROGRESS_COLUMN_KEYS.has(key as SampleProgressColumnKey)
|
||||
) {
|
||||
normalized.add(key as SampleProgressColumnKey)
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...normalized,
|
||||
...DEFAULT_SAMPLE_PROGRESS_COLUMN_ORDER.filter((key) => !normalized.has(key)),
|
||||
]
|
||||
}
|
||||
|
||||
export function getOrderedSampleProgressColumns(order: ReadonlyArray<SampleProgressColumnKey> = DEFAULT_SAMPLE_PROGRESS_COLUMN_ORDER) {
|
||||
const normalizedOrder = normalizeSampleProgressColumnOrder(order)
|
||||
const columnsByKey = new Map(SAMPLE_PROGRESS_COLUMNS.map((column) => [column.key, column]))
|
||||
const lockedLeft = SAMPLE_PROGRESS_COLUMNS.filter((column) => column.locked && column.key !== 'actions')
|
||||
const lockedRight = SAMPLE_PROGRESS_COLUMNS.filter((column) => column.key === 'actions')
|
||||
const movable = normalizedOrder
|
||||
.map((key) => columnsByKey.get(key))
|
||||
.filter((column): column is SampleProgressColumn => Boolean(column))
|
||||
return [...lockedLeft, ...movable, ...lockedRight]
|
||||
}
|
||||
|
||||
export function getVisibleSampleProgressColumns(
|
||||
hiddenKeys: ReadonlySet<SampleProgressColumnKey>,
|
||||
order: ReadonlyArray<SampleProgressColumnKey> = DEFAULT_SAMPLE_PROGRESS_COLUMN_ORDER,
|
||||
) {
|
||||
return getOrderedSampleProgressColumns(order).filter((column) => column.locked || !hiddenKeys.has(column.key))
|
||||
}
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import type { SampleProgressDTO } from '@/server/services/sample-progress.service'
|
||||
import { normalizeSampleProgressStatus } from '@/lib/sample-progress'
|
||||
|
||||
export type SampleProgressSupplierView = NonNullable<SampleProgressDTO['suppliers']>[number]
|
||||
|
||||
export function sampleProgressSuppliers(item: SampleProgressDTO): SampleProgressSupplierView[] {
|
||||
if (item.suppliers?.length) return item.suppliers
|
||||
if (item.suppliers?.length) return item.suppliers.map((supplier) => ({
|
||||
...supplier,
|
||||
status: normalizeSampleProgressStatus(supplier.status),
|
||||
}))
|
||||
return [{
|
||||
id: `legacy_${item.id}`,
|
||||
sortOrder: 0,
|
||||
@ -12,7 +16,7 @@ export function sampleProgressSuppliers(item: SampleProgressDTO): SampleProgress
|
||||
sampleNotifiedAt: item.sampleNotifiedAt ?? null,
|
||||
sampleDueDate: item.sampleDueDate,
|
||||
courierTrackingNumber: item.courierTrackingNumber ?? null,
|
||||
status: item.status,
|
||||
status: normalizeSampleProgressStatus(item.status),
|
||||
requestedCost: item.requestedCost,
|
||||
actualCost: item.actualCost,
|
||||
progressDescription: item.progressDescription,
|
||||
@ -24,6 +28,6 @@ export function isSampleProgressSupplierOverdue(
|
||||
supplier: SampleProgressSupplierView,
|
||||
todayUtc: number,
|
||||
) {
|
||||
return !['COMPLETED', 'CANCELLED', 'TERMINATED'].includes(supplier.status)
|
||||
return !['COMPLETED', 'TERMINATED'].includes(normalizeSampleProgressStatus(supplier.status))
|
||||
&& new Date(supplier.sampleDueDate).getTime() < todayUtc
|
||||
}
|
||||
|
||||
@ -1,11 +1,19 @@
|
||||
import { SAMPLE_PROGRESS_COLUMNS, getVisibleSampleProgressColumns } from './sampleProgressColumns'
|
||||
import {
|
||||
DEFAULT_SAMPLE_PROGRESS_COLUMN_ORDER,
|
||||
LOCKED_SAMPLE_PROGRESS_COLUMN_KEYS,
|
||||
SAMPLE_PROGRESS_COLUMNS,
|
||||
getVisibleSampleProgressColumns,
|
||||
normalizeSampleProgressColumnOrder,
|
||||
} from './sampleProgressColumns'
|
||||
import {
|
||||
buildSampleProgressColumnStorageKey,
|
||||
normalizeHiddenSampleProgressColumnKeys,
|
||||
useSampleProgressColumnVisibility,
|
||||
} from './useSampleProgressColumnVisibility'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
|
||||
describe('sample progress column visibility', () => {
|
||||
it('places due date and status between sample duration and product size', () => {
|
||||
it('places due date and status between sample duration and product spec', () => {
|
||||
const labels = SAMPLE_PROGRESS_COLUMNS.map((column) => column.label)
|
||||
const sampleDurationIndex = labels.indexOf('打样时间')
|
||||
|
||||
@ -13,10 +21,16 @@ describe('sample progress column visibility', () => {
|
||||
'打样时间',
|
||||
'打样交期',
|
||||
'打样状态',
|
||||
'产品尺寸',
|
||||
'产品规格',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not expose supplier confirmation time as a list column', () => {
|
||||
const labels = SAMPLE_PROGRESS_COLUMNS.map((column) => column.label)
|
||||
expect(labels).not.toContain('供应商确认时间')
|
||||
expect(labels).not.toContain('颜色')
|
||||
})
|
||||
|
||||
it('ignores unknown and locked columns in persisted hidden settings', () => {
|
||||
expect(normalizeHiddenSampleProgressColumnKeys([
|
||||
'brand',
|
||||
@ -51,4 +65,29 @@ describe('sample progress column visibility', () => {
|
||||
buildSampleProgressColumnStorageKey('半夏'),
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes a custom order while keeping fixed columns at the edges', () => {
|
||||
const order = normalizeSampleProgressColumnOrder(['status', 'brand', 'status', 'image', 'actions'])
|
||||
const visible = getVisibleSampleProgressColumns(new Set(), order)
|
||||
expect(visible[0]?.key).toBe('image')
|
||||
expect(visible[1]?.key).toBe('openingCode')
|
||||
expect(visible[2]?.key).toBe('status')
|
||||
expect(visible[3]?.key).toBe('brand')
|
||||
expect(visible.at(-1)?.key).toBe('actions')
|
||||
expect(new Set(order).size).toBe(order.length)
|
||||
})
|
||||
|
||||
it('hides all movable columns while keeping locked columns visible', () => {
|
||||
const { result } = renderHook(() => useSampleProgressColumnVisibility('测试账号'))
|
||||
|
||||
act(() => {
|
||||
result.current.hideAllColumns()
|
||||
})
|
||||
|
||||
expect(result.current.hiddenCount).toBe(DEFAULT_SAMPLE_PROGRESS_COLUMN_ORDER.length)
|
||||
expect(result.current.visibleColumns.map((column) => column.key)).toEqual(
|
||||
expect.arrayContaining([...LOCKED_SAMPLE_PROGRESS_COLUMN_KEYS]),
|
||||
)
|
||||
expect(result.current.visibleColumns).toHaveLength(LOCKED_SAMPLE_PROGRESS_COLUMN_KEYS.size)
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,19 +2,27 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
DEFAULT_SAMPLE_PROGRESS_COLUMN_ORDER,
|
||||
LOCKED_SAMPLE_PROGRESS_COLUMN_KEYS,
|
||||
SAMPLE_PROGRESS_COLUMN_KEYS,
|
||||
getVisibleSampleProgressColumns,
|
||||
normalizeSampleProgressColumnOrder,
|
||||
type SampleProgressColumnKey,
|
||||
} from './sampleProgressColumns'
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'sample-progress-hidden-columns'
|
||||
const ORDER_STORAGE_KEY_PREFIX = 'sample-progress-column-order'
|
||||
|
||||
export function buildSampleProgressColumnStorageKey(currentUserName: string) {
|
||||
const userKey = currentUserName.trim().toLocaleLowerCase() || 'anonymous'
|
||||
return `${STORAGE_KEY_PREFIX}:${encodeURIComponent(userKey)}`
|
||||
}
|
||||
|
||||
export function buildSampleProgressColumnOrderStorageKey(currentUserName: string) {
|
||||
const userKey = currentUserName.trim().toLocaleLowerCase() || 'anonymous'
|
||||
return `${ORDER_STORAGE_KEY_PREFIX}:${encodeURIComponent(userKey)}`
|
||||
}
|
||||
|
||||
export function normalizeHiddenSampleProgressColumnKeys(value: unknown): SampleProgressColumnKey[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
@ -41,6 +49,16 @@ function loadHiddenColumnKeys(storageKey: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function loadColumnOrder(storageKey: string) {
|
||||
if (typeof window === 'undefined') return [...DEFAULT_SAMPLE_PROGRESS_COLUMN_ORDER]
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
return raw ? normalizeSampleProgressColumnOrder(JSON.parse(raw)) : [...DEFAULT_SAMPLE_PROGRESS_COLUMN_ORDER]
|
||||
} catch {
|
||||
return [...DEFAULT_SAMPLE_PROGRESS_COLUMN_ORDER]
|
||||
}
|
||||
}
|
||||
|
||||
export function useSampleProgressColumnVisibility(currentUserName: string) {
|
||||
const storageKey = useMemo(
|
||||
() => buildSampleProgressColumnStorageKey(currentUserName),
|
||||
@ -48,12 +66,23 @@ export function useSampleProgressColumnVisibility(currentUserName: string) {
|
||||
)
|
||||
const [hiddenColumnKeys, setHiddenColumnKeys] = useState<SampleProgressColumnKey[]>([])
|
||||
const [loadedStorageKey, setLoadedStorageKey] = useState<string | null>(null)
|
||||
const orderStorageKey = useMemo(
|
||||
() => buildSampleProgressColumnOrderStorageKey(currentUserName),
|
||||
[currentUserName],
|
||||
)
|
||||
const [columnOrder, setColumnOrder] = useState<SampleProgressColumnKey[]>(() => [...DEFAULT_SAMPLE_PROGRESS_COLUMN_ORDER])
|
||||
const [loadedOrderStorageKey, setLoadedOrderStorageKey] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setHiddenColumnKeys(loadHiddenColumnKeys(storageKey))
|
||||
setLoadedStorageKey(storageKey)
|
||||
}, [storageKey])
|
||||
|
||||
useEffect(() => {
|
||||
setColumnOrder(loadColumnOrder(orderStorageKey))
|
||||
setLoadedOrderStorageKey(orderStorageKey)
|
||||
}, [orderStorageKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || loadedStorageKey !== storageKey) return
|
||||
try {
|
||||
@ -63,10 +92,19 @@ export function useSampleProgressColumnVisibility(currentUserName: string) {
|
||||
}
|
||||
}, [hiddenColumnKeys, loadedStorageKey, storageKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || loadedOrderStorageKey !== orderStorageKey) return
|
||||
try {
|
||||
window.localStorage.setItem(orderStorageKey, JSON.stringify(columnOrder))
|
||||
} catch {
|
||||
// Browser privacy settings can disable local storage; ordering still works for this session.
|
||||
}
|
||||
}, [columnOrder, loadedOrderStorageKey, orderStorageKey])
|
||||
|
||||
const hiddenColumnKeySet = useMemo(() => new Set(hiddenColumnKeys), [hiddenColumnKeys])
|
||||
const visibleColumns = useMemo(
|
||||
() => getVisibleSampleProgressColumns(hiddenColumnKeySet),
|
||||
[hiddenColumnKeySet],
|
||||
() => getVisibleSampleProgressColumns(hiddenColumnKeySet, columnOrder),
|
||||
[columnOrder, hiddenColumnKeySet],
|
||||
)
|
||||
const visibleColumnKeySet = useMemo(
|
||||
() => new Set(visibleColumns.map((column) => column.key)),
|
||||
@ -84,11 +122,35 @@ export function useSampleProgressColumnVisibility(currentUserName: string) {
|
||||
|
||||
const showAllColumns = useCallback(() => setHiddenColumnKeys([]), [])
|
||||
|
||||
const hideAllColumns = useCallback(() => {
|
||||
setHiddenColumnKeys([...DEFAULT_SAMPLE_PROGRESS_COLUMN_ORDER])
|
||||
}, [])
|
||||
|
||||
const moveColumn = useCallback((sourceKey: SampleProgressColumnKey, targetKey: SampleProgressColumnKey) => {
|
||||
if (
|
||||
sourceKey === targetKey
|
||||
|| LOCKED_SAMPLE_PROGRESS_COLUMN_KEYS.has(sourceKey)
|
||||
|| LOCKED_SAMPLE_PROGRESS_COLUMN_KEYS.has(targetKey)
|
||||
) return
|
||||
setColumnOrder((current) => {
|
||||
const sourceIndex = current.indexOf(sourceKey)
|
||||
const targetIndex = current.indexOf(targetKey)
|
||||
if (sourceIndex < 0 || targetIndex < 0) return current
|
||||
const next = [...current]
|
||||
next.splice(sourceIndex, 1)
|
||||
next.splice(targetIndex - (sourceIndex < targetIndex ? 1 : 0), 0, sourceKey)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
hiddenCount: hiddenColumnKeys.length,
|
||||
visibleColumns,
|
||||
visibleColumnKeySet,
|
||||
toggleColumn,
|
||||
showAllColumns,
|
||||
hideAllColumns,
|
||||
columnOrder,
|
||||
moveColumn,
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca
|
||||
const [data, setData] = useState(initialData)
|
||||
const [filters, setFilters] = useState<FinishedProductProgressFilters>({ ...EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS })
|
||||
const [editor, setEditor] = useState<FinishedProductProgressDetailRow | 'new' | null>(null)
|
||||
const [viewer, setViewer] = useState<FinishedProductProgressDetailRow | null>(null)
|
||||
const [logs, setLogs] = useState<FinishedProductProgressChangeLogEntry[] | null>(null)
|
||||
const [preview, setPreview] = useState<FinishedProductProgressDetailRow | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@ -96,10 +97,11 @@ 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>
|
||||
</form>
|
||||
<section className="finished-product-table-shell">
|
||||
<FinishedProductTable rows={data.rows} columns={columns.visibleColumns} canEdit={canEdit} onEdit={setEditor} onHistory={(row) => void openHistory(row)} onPreview={setPreview} />
|
||||
<FinishedProductTable rows={data.rows} columns={columns.visibleColumns} canEdit={canEdit} onEdit={setEditor} onView={setViewer} onHistory={(row) => void openHistory(row)} onPreview={setPreview} />
|
||||
<FinishedProductPagination {...data.pageInfo} loading={loading} pageInput={pageInput} onPageInput={setPageInput} onCommitPage={commitPage} onPage={(page) => void load(filters, page, data.pageInfo.pageSize)} onPageSize={(pageSize) => void load(filters, 1, pageSize)} />
|
||||
</section>
|
||||
{editor && <FinishedProductEditorDrawer item={editor === 'new' ? null : editor} onClose={() => setEditor(null)} onRowUpdated={updateRow} onSaved={() => { setEditor(null); void load(filters, data.pageInfo.page, data.pageInfo.pageSize) }} />}
|
||||
{viewer && <FinishedProductEditorDrawer item={viewer} readOnly onClose={() => setViewer(null)} onRowUpdated={updateRow} onSaved={() => setViewer(null)} />}
|
||||
{logs && <FinishedProductHistoryDialog logs={logs} onClose={() => setLogs(null)} />}
|
||||
<FinishedProductImportProgress state={importTask.state} onDismiss={importTask.dismiss} />
|
||||
{preview?.imageUrl && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/80 p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) setPreview(null) }}><div className="relative h-[min(82vh,900px)] w-[min(90vw,1200px)]"><Image unoptimized src={preview.imageUrl} alt={preview.productName || preview.barcode} fill sizes="90vw" className="object-contain" /><button type="button" title="关闭" onClick={() => setPreview(null)} className="absolute right-0 top-0 flex h-10 w-10 items-center justify-center rounded-full bg-black/70 text-white"><X size={20} /></button></div></div>}
|
||||
|
||||
@ -18,7 +18,7 @@ async function readResponse(response: Response) {
|
||||
|
||||
type Draft = { deliveryDate: string; quantity: number }
|
||||
|
||||
export function FinishedProductDeliveryRecords({ item, onUpdated }: { item: FinishedProductProgressDetailRow | null; onUpdated: (row: FinishedProductProgressDetailRow) => void }) {
|
||||
export function FinishedProductDeliveryRecords({ item, readOnly = false, onUpdated }: { item: FinishedProductProgressDetailRow | null; readOnly?: boolean; onUpdated: (row: FinishedProductProgressDetailRow) => void }) {
|
||||
const [draft, setDraft] = useState<Draft>({ deliveryDate: today(), quantity: 0 })
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editingDraft, setEditingDraft] = useState<Draft>({ deliveryDate: '', quantity: 0 })
|
||||
@ -70,10 +70,28 @@ export function FinishedProductDeliveryRecords({ item, onUpdated }: { item: Fini
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-[5px] border border-zinc-200">
|
||||
<table className="w-full min-w-[620px] text-left text-xs"><thead className="bg-zinc-50 text-zinc-500"><tr><th className="px-3 py-2 font-semibold">送货日期</th><th className="px-3 py-2 font-semibold">送货数量</th><th className="px-3 py-2 font-semibold">状态</th><th className="px-3 py-2 font-semibold">操作人</th><th className="px-3 py-2 text-right font-semibold">操作</th></tr></thead><tbody>
|
||||
{records.map((record) => editingId === record.id ? <tr key={record.id} className="border-t border-zinc-100"><td className="px-3 py-2"><input type="date" value={editingDraft.deliveryDate} onChange={(event) => setEditingDraft({ ...editingDraft, deliveryDate: event.target.value })} className="h-8 rounded border border-zinc-300 px-2" /></td><td className="px-3 py-2"><input type="number" min={0} step={1} value={editingDraft.quantity} onChange={(event) => setEditingDraft({ ...editingDraft, quantity: Math.max(0, Math.trunc(Number(event.target.value) || 0)) })} className="h-8 w-28 rounded border border-zinc-300 px-2" /></td><td className="px-3 py-2 text-zinc-500">编辑中</td><td className="px-3 py-2 text-zinc-500">{record.updatedByName || record.createdByName || '-'}</td><td className="px-3 py-2 text-right"><button type="button" disabled={busy} onClick={() => void saveEdit()} className="finished-row-button"><Check size={13} />保存</button><button type="button" disabled={busy} onClick={() => setEditingId(null)} className="finished-row-button"><X size={13} />取消</button></td></tr> : <tr key={record.id} className="border-t border-zinc-100"><td className="px-3 py-2 font-mono text-zinc-800">{record.deliveryDate}</td><td className="px-3 py-2 tabular-nums text-zinc-800">{record.quantity.toLocaleString('zh-CN')}</td><td className="px-3 py-2">{record.status === 'VOID' ? <span className="text-zinc-400">已作废</span> : <span className="text-emerald-700">有效</span>}</td><td className="px-3 py-2 text-zinc-500">{record.updatedByName || record.createdByName || '-'}</td><td className="px-3 py-2 text-right">{record.status === 'ACTIVE' && <><button type="button" disabled={busy} onClick={() => startEdit(record)} className="finished-row-button"><Pencil size={13} />编辑</button><button type="button" disabled={busy} onClick={() => void voidRecord(record)} className="finished-row-button"><Trash2 size={13} />作废</button></>}</td></tr>)}
|
||||
{records.map((record) => {
|
||||
const isEditing = editingId === record.id && !readOnly
|
||||
if (isEditing) {
|
||||
return <tr key={record.id} className="border-t border-zinc-100">
|
||||
<td className="px-3 py-2"><input type="date" value={editingDraft.deliveryDate} onChange={(event) => setEditingDraft({ ...editingDraft, deliveryDate: event.target.value })} className="h-8 rounded border border-zinc-300 px-2" /></td>
|
||||
<td className="px-3 py-2"><input type="number" min={0} step={1} value={editingDraft.quantity} onChange={(event) => setEditingDraft({ ...editingDraft, quantity: Math.max(0, Math.trunc(Number(event.target.value) || 0)) })} className="h-8 w-28 rounded border border-zinc-300 px-2" /></td>
|
||||
<td className="px-3 py-2 text-zinc-500">编辑中</td>
|
||||
<td className="px-3 py-2 text-zinc-500">{record.updatedByName || record.createdByName || '-'}</td>
|
||||
<td className="px-3 py-2 text-right"><button type="button" disabled={busy} onClick={() => void saveEdit()} className="finished-row-button" title="Save" aria-label="Save"><Check size={13} /></button><button type="button" disabled={busy} onClick={() => setEditingId(null)} className="finished-row-button" title="Cancel" aria-label="Cancel"><X size={13} /></button></td>
|
||||
</tr>
|
||||
}
|
||||
return <tr key={record.id} className="border-t border-zinc-100">
|
||||
<td className="px-3 py-2 font-mono text-zinc-800">{record.deliveryDate}</td>
|
||||
<td className="px-3 py-2 tabular-nums text-zinc-800">{record.quantity.toLocaleString('zh-CN')}</td>
|
||||
<td className="px-3 py-2">{record.status === 'VOID' ? <span className="text-zinc-400">已作废</span> : <span className="text-emerald-700">有效</span>}</td>
|
||||
<td className="px-3 py-2 text-zinc-500">{record.updatedByName || record.createdByName || '-'}</td>
|
||||
<td className="px-3 py-2 text-right">{!readOnly && record.status === 'ACTIVE' && <><button type="button" disabled={busy} onClick={() => startEdit(record)} className="finished-row-button" title="Edit" aria-label="Edit"><Pencil size={13} /></button><button type="button" disabled={busy} onClick={() => void voidRecord(record)} className="finished-row-button" title="Void" aria-label="Void"><Trash2 size={13} /></button></>}</td>
|
||||
</tr>
|
||||
})}
|
||||
{records.length === 0 && <tr><td colSpan={5} className="px-3 py-8 text-center text-zinc-500">暂无送货记录</td></tr>}
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-end gap-3 border-t border-zinc-100 pt-4"><label className="finished-field"><span>送货日期</span><input type="date" value={draft.deliveryDate} onChange={(event) => setDraft({ ...draft, deliveryDate: event.target.value })} /></label><label className="finished-field"><span>送货数量</span><input type="number" min={0} step={1} value={draft.quantity} onChange={(event) => setDraft({ ...draft, quantity: Math.max(0, Math.trunc(Number(event.target.value) || 0)) })} /></label><button type="button" disabled={busy} onClick={() => void submit()} className="finished-primary-button"><Plus size={14} />新增送货</button><button type="button" disabled={busy} onClick={() => setDraft({ deliveryDate: today(), quantity: 0 })} className="finished-control-button"><RotateCcw size={14} />重置</button></div>
|
||||
{!readOnly && <div className="mt-4 flex flex-wrap items-end gap-3 border-t border-zinc-100 pt-4"><label className="finished-field"><span>送货日期</span><input type="date" value={draft.deliveryDate} onChange={(event) => setDraft({ ...draft, deliveryDate: event.target.value })} /></label><label className="finished-field"><span>送货数量</span><input type="number" min={0} step={1} value={draft.quantity} onChange={(event) => setDraft({ ...draft, quantity: Math.max(0, Math.trunc(Number(event.target.value) || 0)) })} /></label><button type="button" disabled={busy} onClick={() => void submit()} className="finished-primary-button"><Plus size={14} />新增送货</button><button type="button" disabled={busy} onClick={() => setDraft({ deliveryDate: today(), quantity: 0 })} className="finished-control-button"><RotateCcw size={14} />重置</button></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import type { FinishedProductProgressDetailRow } from '@/lib/finished-product-progress'
|
||||
import { FinishedProductEditorDrawer } from './FinishedProductEditorDrawer'
|
||||
|
||||
const item = {
|
||||
id: 'finished-1',
|
||||
contractDateNumber: '260831001',
|
||||
contractNumber: 'HT-001',
|
||||
imageUrl: '',
|
||||
itemNumber: 'SKU-001',
|
||||
barcode: '',
|
||||
productName: '测试产品',
|
||||
supplier: '测试供应商',
|
||||
brand: '测试品牌',
|
||||
orderedQuantity: 100,
|
||||
inTransitQuantity: 60,
|
||||
deliveryQuantity: 40,
|
||||
orderDueDate: '2026-09-10',
|
||||
realtimeDate: '2026-08-31',
|
||||
remarks: '备注内容',
|
||||
currentNode: '生产中',
|
||||
completedAt: null,
|
||||
createdAt: '2026-08-31T00:00:00.000Z',
|
||||
updatedAt: '2026-08-31T00:00:00.000Z',
|
||||
updatedByName: '测试用户',
|
||||
deliveryRecords: [],
|
||||
} as FinishedProductProgressDetailRow
|
||||
|
||||
describe('FinishedProductEditorDrawer', () => {
|
||||
it('renders a read-only view without save or delivery controls', () => {
|
||||
render(<FinishedProductEditorDrawer item={item} readOnly onClose={jest.fn()} onSaved={jest.fn()} />)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'View finished product progress' })).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('HT-001')).toBeDisabled()
|
||||
expect(screen.getByDisplayValue('备注内容')).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新增送货' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -30,13 +30,14 @@ function toDraft(row: FinishedProductProgressDetailRow | null): FinishedProductP
|
||||
}
|
||||
}
|
||||
|
||||
export function FinishedProductEditorDrawer({ item, onClose, onSaved, onRowUpdated }: { item: FinishedProductProgressDetailRow | null; onClose: () => void; onSaved: (row: FinishedProductProgressDetailRow) => void; onRowUpdated?: (row: FinishedProductProgressDetailRow) => void }) {
|
||||
export function FinishedProductEditorDrawer({ item, readOnly = false, onClose, onSaved, onRowUpdated }: { item: FinishedProductProgressDetailRow | null; readOnly?: boolean; onClose: () => void; onSaved: (row: FinishedProductProgressDetailRow) => void; onRowUpdated?: (row: FinishedProductProgressDetailRow) => void }) {
|
||||
const [workingItem, setWorkingItem] = useState(item)
|
||||
const [draft, setDraft] = useState(() => toDraft(item))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const computedInTransitQuantity = calculateFinishedProductInTransitQuantity(draft.orderedQuantity, draft.deliveryQuantity)
|
||||
const set = <K extends keyof FinishedProductProgressInput>(key: K, value: FinishedProductProgressInput[K]) => setDraft((current) => ({ ...current, [key]: value }))
|
||||
const save = async () => {
|
||||
if (readOnly) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch(workingItem ? `/api/supply-chain/finished-product-progress/${workingItem.id}` : '/api/supply-chain/finished-product-progress', { method: workingItem ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...draft, inTransitQuantity: computedInTransitQuantity }) })
|
||||
@ -46,41 +47,41 @@ export function FinishedProductEditorDrawer({ item, onClose, onSaved, onRowUpdat
|
||||
} catch (error) { window.alert(error instanceof Error ? error.message : '保存失败') } finally { setSaving(false) }
|
||||
}
|
||||
return <div className="fixed inset-0 z-[70] flex justify-end bg-black/30" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) onClose() }}>
|
||||
<section className="flex h-full w-full max-w-[780px] flex-col bg-[#f7f8f8] shadow-2xl">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-black/10 bg-white px-6"><div><h2 className="text-base font-bold text-zinc-900">{item ? '编辑成品进度' : '新增成品进度'}</h2><p className="mt-0.5 text-xs text-zinc-500">日期号由系统生成且固定;条码没有时可留空</p></div><button type="button" title="关闭" disabled={saving} onClick={onClose} className="finished-icon-button"><X size={18} /></button></header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<section role="dialog" aria-modal="true" aria-label={readOnly ? 'View finished product progress' : item ? 'Edit finished product progress' : 'Add finished product progress'} className="flex h-full w-full max-w-[780px] flex-col bg-[#f7f8f8] shadow-2xl">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-black/10 bg-white px-6"><div><h2 className="text-base font-bold text-zinc-900">{readOnly ? '查看成品进度' : item ? '编辑成品进度' : '新增成品进度'}</h2><p className="mt-0.5 text-xs text-zinc-500">日期号由系统生成且固定;条码没有时可留空</p></div><button type="button" title="Close" aria-label="Close" disabled={saving} onClick={onClose} className="finished-icon-button"><X size={18} /></button></header>
|
||||
<fieldset disabled={readOnly} className="min-h-0 flex-1 overflow-y-auto border-0 p-6">
|
||||
<div className="grid gap-4 rounded-[7px] border border-black/10 bg-white p-5 sm:grid-cols-2">
|
||||
<ReadOnlyField label="日期号(系统生成)" value={draft.contractDateNumber ?? ''} placeholder="保存后自动生成" />
|
||||
<Field label="合同号" required value={draft.contractNumber} onChange={(value) => set('contractNumber', value)} />
|
||||
<Field label="条码" value={draft.barcode} onChange={(value) => set('barcode', value)} />
|
||||
<Field label="货号" value={draft.itemNumber} onChange={(value) => set('itemNumber', value)} />
|
||||
<Field label="产品名称" value={draft.productName} onChange={(value) => set('productName', value)} />
|
||||
<Field label="供应商" value={draft.supplier} onChange={(value) => set('supplier', value)} />
|
||||
<Field label="品牌" value={draft.brand} onChange={(value) => set('brand', value)} />
|
||||
<label className="finished-field"><span>订单交期(周更)</span><input type="date" value={draft.orderDueDate ?? ''} onChange={(event) => set('orderDueDate', event.target.value || null)} /></label>
|
||||
<NumberField label="下单数量" value={draft.orderedQuantity} onChange={(value) => set('orderedQuantity', value)} />
|
||||
<Field label="合同号" required value={draft.contractNumber} onChange={(value) => set('contractNumber', value)} disabled={readOnly} />
|
||||
<Field label="条码" value={draft.barcode} onChange={(value) => set('barcode', value)} disabled={readOnly} />
|
||||
<Field label="货号" value={draft.itemNumber} onChange={(value) => set('itemNumber', value)} disabled={readOnly} />
|
||||
<Field label="产品名称" value={draft.productName} onChange={(value) => set('productName', value)} disabled={readOnly} />
|
||||
<Field label="供应商" value={draft.supplier} onChange={(value) => set('supplier', value)} disabled={readOnly} />
|
||||
<Field label="品牌" value={draft.brand} onChange={(value) => set('brand', value)} disabled={readOnly} />
|
||||
<label className="finished-field"><span>订单交期(周更)</span><input disabled={readOnly} type="date" value={draft.orderDueDate ?? ''} onChange={(event) => set('orderDueDate', event.target.value || null)} /></label>
|
||||
<NumberField label="下单数量" value={draft.orderedQuantity} onChange={(value) => set('orderedQuantity', value)} disabled={readOnly} />
|
||||
<NumberField label="在途数量(自动)" value={computedInTransitQuantity} readOnly />
|
||||
<NumberField label="累计送货数量(自动)" value={draft.deliveryQuantity} readOnly />
|
||||
<ReadOnlyField label="最近送货日期(自动)" value={draft.realtimeDate ?? ''} placeholder="新增送货记录后自动汇总" />
|
||||
<label className="finished-field sm:col-span-2"><span>备注</span><textarea rows={4} value={draft.remarks} onChange={(event) => set('remarks', event.target.value)} placeholder="填写备注" /></label>
|
||||
<Field label="当前节点" value={draft.currentNode} onChange={(value) => set('currentNode', value)} />
|
||||
<label className="finished-field sm:col-span-2"><span>图片 URL</span><input value={draft.imageUrl} onChange={(event) => set('imageUrl', event.target.value)} placeholder="https://..." /></label>
|
||||
<label className="finished-field sm:col-span-2"><span>备注</span><textarea disabled={readOnly} rows={4} value={draft.remarks} onChange={(event) => set('remarks', event.target.value)} placeholder="填写备注" /></label>
|
||||
<Field label="当前节点" value={draft.currentNode} onChange={(value) => set('currentNode', value)} disabled={readOnly} />
|
||||
<label className="finished-field sm:col-span-2"><span>图片 URL</span><input disabled={readOnly} value={draft.imageUrl} onChange={(event) => set('imageUrl', event.target.value)} placeholder="https://..." /></label>
|
||||
</div>
|
||||
{workingItem && <div className="mt-4"><FinishedProductDeliveryRecords item={workingItem} onUpdated={(next) => { setWorkingItem(next); setDraft(toDraft(next)); onRowUpdated?.(next) }} /></div>}
|
||||
</div>
|
||||
<footer className="flex shrink-0 justify-end gap-3 border-t border-black/10 bg-white px-6 py-4"><button type="button" disabled={saving} onClick={onClose} className="finished-secondary-button">取消</button><button type="button" disabled={saving} onClick={() => void save()} className="finished-primary-button"><Save size={15} />{saving ? '保存中...' : '保存'}</button></footer>
|
||||
{workingItem && <div className="mt-4"><FinishedProductDeliveryRecords item={workingItem} readOnly={readOnly} onUpdated={(next) => { setWorkingItem(next); setDraft(toDraft(next)); onRowUpdated?.(next) }} /></div>}
|
||||
</fieldset>
|
||||
{!readOnly && <footer className="flex shrink-0 justify-end gap-3 border-t border-black/10 bg-white px-6 py-4"><button type="button" disabled={saving} onClick={onClose} className="finished-secondary-button">取消</button><button type="button" disabled={saving} onClick={() => void save()} className="finished-primary-button"><Save size={15} />{saving ? '保存中...' : '保存'}</button></footer>}
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
function Field({ label, value, onChange, required = false }: { label: string; value: string; onChange: (value: string) => void; required?: boolean }) {
|
||||
return <label className="finished-field"><span>{label}{required && <em>*</em>}</span><input value={value} onChange={(event) => onChange(event.target.value)} /></label>
|
||||
function Field({ label, value, onChange, required = false, disabled = false }: { label: string; value: string; onChange: (value: string) => void; required?: boolean; disabled?: boolean }) {
|
||||
return <label className="finished-field"><span>{label}{required && <em>*</em>}</span><input disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)} /></label>
|
||||
}
|
||||
|
||||
function ReadOnlyField({ label, value, placeholder }: { label: string; value: string; placeholder?: string }) {
|
||||
return <label className="finished-field"><span>{label}</span><input value={value} placeholder={placeholder} readOnly aria-readonly="true" className="bg-zinc-100 text-zinc-500" /></label>
|
||||
}
|
||||
|
||||
function NumberField({ label, value, onChange, readOnly = false }: { label: string; value: number; onChange?: (value: number) => void; readOnly?: boolean }) {
|
||||
return <label className="finished-field"><span>{label}</span><input type="number" min={0} step={1} value={value} readOnly={readOnly} aria-readonly={readOnly} onChange={onChange ? (event) => onChange(Math.max(0, Math.trunc(Number(event.target.value) || 0))) : undefined} className={readOnly ? 'bg-zinc-100 text-zinc-500' : undefined} /></label>
|
||||
function NumberField({ label, value, onChange, readOnly = false, disabled = false }: { label: string; value: number; onChange?: (value: number) => void; readOnly?: boolean; disabled?: boolean }) {
|
||||
return <label className="finished-field"><span>{label}</span><input type="number" min={0} step={1} value={value} readOnly={readOnly} disabled={disabled} aria-readonly={readOnly || disabled} onChange={onChange ? (event) => onChange(Math.max(0, Math.trunc(Number(event.target.value) || 0))) : undefined} className={readOnly || disabled ? 'bg-zinc-100 text-zinc-500' : undefined} /></label>
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { History, Pencil } from 'lucide-react'
|
||||
import { Eye, History, Pencil } from 'lucide-react'
|
||||
import type { FinishedProductProgressDetailRow, FinishedProductProgressRow, FinishedProductProgressSummaryRow } from '@/lib/finished-product-progress'
|
||||
import type { FinishedProductProgressColumn, FinishedProductProgressColumnKey } from '../finishedProductProgressColumns'
|
||||
|
||||
export function FinishedProductTable({ rows, columns, canEdit, onEdit, onHistory, onPreview }: { rows: FinishedProductProgressSummaryRow[]; columns: readonly FinishedProductProgressColumn[]; canEdit: boolean; onEdit: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void }) {
|
||||
export function FinishedProductTable({ rows, columns, canEdit, onEdit, onView, onHistory, onPreview }: { rows: FinishedProductProgressSummaryRow[]; columns: readonly FinishedProductProgressColumn[]; canEdit: boolean; onEdit: (row: FinishedProductProgressRow) => void; onView: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void }) {
|
||||
const minWidth = columns.reduce((total, column) => total + column.minWidth, 0)
|
||||
const detailCount = rows.reduce((total, group) => total + group.details.length, 0)
|
||||
return <div className="min-h-0 flex-1 overflow-auto overscroll-contain">
|
||||
@ -15,17 +15,17 @@ export function FinishedProductTable({ rows, columns, canEdit, onEdit, onHistory
|
||||
? index === 0
|
||||
? <td key={column.key} rowSpan={group.details.length} className="sticky left-0 z-10 align-middle whitespace-nowrap border-b border-zinc-100 bg-white px-3 py-3 font-mono text-zinc-900 group-hover:bg-zinc-50">{group.contractDateNumber || '-'}</td>
|
||||
: null
|
||||
: <FinishedProductCell key={column.key} column={column.key} row={row} canEdit={canEdit} onEdit={onEdit} onHistory={onHistory} onPreview={onPreview} />)}</tr>))}</tbody>
|
||||
: <FinishedProductCell key={column.key} column={column.key} row={row} canEdit={canEdit} onEdit={onEdit} onView={onView} onHistory={onHistory} onPreview={onPreview} />)}</tr>))}</tbody>
|
||||
</table>
|
||||
{detailCount === 0 && <div className="py-20 text-center text-sm text-zinc-500">暂无匹配的成品进度记录</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function FinishedProductCell({ column, row, canEdit, onEdit, onHistory, onPreview }: { column: FinishedProductProgressColumnKey; row: FinishedProductProgressDetailRow; canEdit: boolean; onEdit: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void }) {
|
||||
function FinishedProductCell({ column, row, canEdit, onEdit, onView, onHistory, onPreview }: { column: FinishedProductProgressColumnKey; row: FinishedProductProgressDetailRow; canEdit: boolean; onEdit: (row: FinishedProductProgressRow) => void; onView: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void }) {
|
||||
const base = 'whitespace-nowrap border-b border-zinc-100 bg-white px-3 py-3 text-zinc-700 group-hover:bg-zinc-50'
|
||||
if (column === 'contractNumber') return <td className={`${base} sticky left-[110px] z-10 font-semibold text-zinc-900`}>{row.contractNumber || '-'}</td>
|
||||
if (column === 'imageUrl') return <td className={`${base} sticky left-[260px] z-10`}>{row.imageUrl ? <button type="button" onClick={() => onPreview(row)} className="relative h-12 w-12 overflow-hidden rounded-[5px] border border-zinc-200 bg-zinc-50"><Image unoptimized src={row.imageUrl} alt={row.productName || row.barcode || row.contractNumber} fill sizes="48px" className="object-cover" /></button> : <span className="text-zinc-400">-</span>}</td>
|
||||
if (column === 'actions') return <td className={`${base} finished-product-actions-cell sticky right-0 z-10 border-l border-zinc-200`}><div className="flex flex-nowrap justify-center gap-1">{canEdit && <button type="button" title="编辑" onClick={() => onEdit(row)} className="finished-row-button"><Pencil size={14} />编辑</button>}<button type="button" title="修改日志" onClick={() => onHistory(row)} className="finished-row-button"><History size={14} />日志</button></div></td>
|
||||
if (column === 'actions') return <td className={`${base} finished-product-actions-cell sticky right-0 z-10 border-l border-zinc-200`}><div className="flex flex-nowrap justify-center gap-1 whitespace-nowrap"><button type="button" title="View details" aria-label="View details" onClick={() => onView(row)} className="finished-row-button"><Eye size={14} /></button><button type="button" title="View change history" aria-label="View change history" onClick={() => onHistory(row)} className="finished-row-button"><History size={14} /></button>{canEdit && <button type="button" title="Edit" aria-label="Edit" onClick={() => onEdit(row)} className="finished-row-button"><Pencil size={14} /></button>}</div></td>
|
||||
if (column === 'realtime') return <td className={`${base} tabular-nums`} title={`日期 ${row.realtimeDate || '-'} · 已送货 ${row.deliveryQuantity}`}><span>日期 {row.realtimeDate || '-'} · 已送货 {row.deliveryQuantity.toLocaleString('zh-CN')}</span></td>
|
||||
if (column === 'currentNode') return <td className={base} title={row.inTransitQuantity === 0 && row.orderedQuantity > 0 ? '已完成' : String(row.currentNode || '')}>{row.inTransitQuantity === 0 && row.orderedQuantity > 0 ? '已完成' : String(row.currentNode || '-')}</td>
|
||||
if (column === 'completedAt') return <td className={`${base} tabular-nums`}>{formatCompletedAt(row.completedAt)}</td>
|
||||
|
||||
@ -43,16 +43,25 @@ describe('canEditSampleProgress', () => {
|
||||
})
|
||||
|
||||
describe('resolveSampleProgressEndedAt', () => {
|
||||
it.each(['COMPLETED', 'CANCELLED', 'TERMINATED'])('进入 %s 时记录结束时间', (nextStatus) => {
|
||||
it('进入已完结时记录结束时间', () => {
|
||||
expect(resolveSampleProgressEndedAt({
|
||||
previousStatus: 'PAINT',
|
||||
nextStatus,
|
||||
nextStatus: 'COMPLETED',
|
||||
currentEndedAt: null,
|
||||
now: terminalEndedAt,
|
||||
})).toEqual(terminalEndedAt)
|
||||
})
|
||||
|
||||
it('结束状态之间切换时保留第一次记录的时间', () => {
|
||||
it.each(['CANCELLED', 'TERMINATED'])('进入 %s 时不记录实际完成时间', (nextStatus) => {
|
||||
expect(resolveSampleProgressEndedAt({
|
||||
previousStatus: 'PAINT',
|
||||
nextStatus,
|
||||
currentEndedAt: null,
|
||||
now: terminalEndedAt,
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('已完结改为终止时保留已记录的完成时间', () => {
|
||||
const original = new Date('2026-08-17T03:00:00.000Z')
|
||||
expect(resolveSampleProgressEndedAt({
|
||||
previousStatus: 'COMPLETED',
|
||||
@ -64,7 +73,7 @@ describe('resolveSampleProgressEndedAt', () => {
|
||||
|
||||
it('改回进行中状态时清空结束时间', () => {
|
||||
expect(resolveSampleProgressEndedAt({
|
||||
previousStatus: 'CANCELLED',
|
||||
previousStatus: 'TERMINATED',
|
||||
nextStatus: 'PAINT',
|
||||
currentEndedAt: terminalEndedAt,
|
||||
})).toBeNull()
|
||||
@ -89,7 +98,11 @@ describe('multi-supplier sample progress aggregation', () => {
|
||||
])).toBeNull()
|
||||
expect(resolveSampleProgressAggregateEndedAt([
|
||||
{ status: 'COMPLETED', actualSampleCompletedAt: early },
|
||||
{ status: 'CANCELLED', actualSampleCompletedAt: late },
|
||||
{ status: 'TERMINATED', actualSampleCompletedAt: late },
|
||||
])).toBeNull()
|
||||
expect(resolveSampleProgressAggregateEndedAt([
|
||||
{ status: 'COMPLETED', actualSampleCompletedAt: early },
|
||||
{ status: 'COMPLETED', actualSampleCompletedAt: late },
|
||||
])).toEqual(late)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const SAMPLE_PROGRESS_STATUSES = ['BARE_WOOD', 'BARE_WOOD_REVIEW', 'PAINT', 'VIDEO_REVIEW', 'SAMPLE_SHIPPING', 'PHYSICAL_REVIEW', 'COMPLETED', 'CANCELLED', 'TERMINATED'] as const
|
||||
/** Statuses shown in the UI; legacy CANCELLED is normalized to TERMINATED. */
|
||||
export const SAMPLE_PROGRESS_STATUSES = ['BARE_WOOD', 'BARE_WOOD_REVIEW', 'PAINT', 'VIDEO_REVIEW', 'SAMPLE_SHIPPING', 'PHYSICAL_REVIEW', 'COMPLETED', 'TERMINATED'] as const
|
||||
export const SAMPLE_PROGRESS_STATUS_VALUES = [...SAMPLE_PROGRESS_STATUSES.slice(0, -1), 'CANCELLED', 'TERMINATED'] as const
|
||||
export const SAMPLE_PROGRESS_TERMINAL_STATUSES = ['COMPLETED', 'CANCELLED', 'TERMINATED'] as const
|
||||
export const SAMPLE_PROGRESS_STATUS_LABELS: Record<(typeof SAMPLE_PROGRESS_STATUSES)[number], string> = {
|
||||
BARE_WOOD: '白茬', BARE_WOOD_REVIEW: '白茬审样', PAINT: '油漆', VIDEO_REVIEW: '视频审核成品', SAMPLE_SHIPPING: '寄样', PHYSICAL_REVIEW: '实物审样', COMPLETED: '已完结', CANCELLED: '已撤销', TERMINATED: '已终止',
|
||||
export const SAMPLE_PROGRESS_STATUS_LABELS: Record<(typeof SAMPLE_PROGRESS_STATUS_VALUES)[number], string> = {
|
||||
BARE_WOOD: '白茬', BARE_WOOD_REVIEW: '白茬审样', PAINT: '油漆', VIDEO_REVIEW: '视频审核成品', SAMPLE_SHIPPING: '寄样', PHYSICAL_REVIEW: '实物审样', COMPLETED: '已完结', CANCELLED: '已终止', TERMINATED: '已终止',
|
||||
}
|
||||
const optionalText = z.string().trim().max(1000).optional().nullable()
|
||||
const optionalDate = z.preprocess(
|
||||
@ -26,7 +28,7 @@ export const sampleProgressSupplierInputSchema = z.object({
|
||||
sampleNotifiedAt: optionalDate,
|
||||
sampleDueDate: z.coerce.date(),
|
||||
courierTrackingNumber: optionalText,
|
||||
status: z.enum(SAMPLE_PROGRESS_STATUSES),
|
||||
status: z.enum(SAMPLE_PROGRESS_STATUS_VALUES),
|
||||
requestedCost: z.coerce.number().min(0).max(9999999999).optional().nullable(),
|
||||
actualCost: z.coerce.number().min(0).max(9999999999).optional().nullable(),
|
||||
progressDescription: optionalText,
|
||||
@ -43,7 +45,7 @@ export const sampleProgressInputSchema = z.object({
|
||||
sampleDueDate: z.coerce.date(), supplier: z.string().trim().max(200),
|
||||
supplierConfirmedAt: optionalDate, sampleNotifiedAt: optionalDate,
|
||||
courierTrackingNumber: optionalText, finalSampleFactory: optionalText,
|
||||
status: z.enum(SAMPLE_PROGRESS_STATUSES), designer: z.string().trim().max(100),
|
||||
status: z.enum(SAMPLE_PROGRESS_STATUS_VALUES), designer: z.string().trim().max(100),
|
||||
purchaseOwner: optionalText, merchandiserOwner: optionalText,
|
||||
progressDescription: optionalText,
|
||||
requestedCost: z.coerce.number().min(0).max(9999999999).optional().nullable(), actualCost: z.coerce.number().min(0).max(9999999999).optional().nullable(),
|
||||
@ -54,8 +56,12 @@ export type SampleProgressInput = z.infer<typeof sampleProgressInputSchema>
|
||||
export const sampleProgressUpdateSchema = sampleProgressInputSchema.extend({ deliveryDateChangeReason: z.string().trim().max(1000).optional().nullable() })
|
||||
export type SampleProgressUpdateInput = z.infer<typeof sampleProgressUpdateSchema>
|
||||
|
||||
export function normalizeSampleProgressStatus(status: string) {
|
||||
return status === 'CANCELLED' ? 'TERMINATED' : status
|
||||
}
|
||||
|
||||
export function isSampleProgressTerminalStatus(status: string) {
|
||||
return (SAMPLE_PROGRESS_TERMINAL_STATUSES as readonly string[]).includes(status)
|
||||
return (SAMPLE_PROGRESS_TERMINAL_STATUSES as readonly string[]).includes(normalizeSampleProgressStatus(status))
|
||||
}
|
||||
|
||||
export function resolveSampleProgressEndedAt(input: {
|
||||
@ -64,10 +70,17 @@ export function resolveSampleProgressEndedAt(input: {
|
||||
currentEndedAt: Date | string | null
|
||||
now?: Date
|
||||
}) {
|
||||
const wasTerminal = Boolean(input.previousStatus && isSampleProgressTerminalStatus(input.previousStatus))
|
||||
const isTerminal = isSampleProgressTerminalStatus(input.nextStatus)
|
||||
if (!isTerminal) return null
|
||||
if (wasTerminal) return input.currentEndedAt ? new Date(input.currentEndedAt) : null
|
||||
const previousStatus = input.previousStatus ? normalizeSampleProgressStatus(input.previousStatus) : null
|
||||
const nextStatus = normalizeSampleProgressStatus(input.nextStatus)
|
||||
if (nextStatus !== 'COMPLETED') {
|
||||
// A terminated/cancelled flow is not a completed sample. Preserve a
|
||||
// previously recorded completion timestamp only when the record was
|
||||
// already completed before the status changed.
|
||||
return previousStatus === 'COMPLETED' && input.currentEndedAt
|
||||
? new Date(input.currentEndedAt)
|
||||
: null
|
||||
}
|
||||
if (previousStatus === 'COMPLETED' && input.currentEndedAt) return new Date(input.currentEndedAt)
|
||||
if (input.currentEndedAt) return new Date(input.currentEndedAt)
|
||||
return input.now ?? new Date()
|
||||
}
|
||||
@ -82,7 +95,8 @@ const SAMPLE_PROGRESS_ACTIVE_STATUS_ORDER = new Map<string, number>(
|
||||
)
|
||||
|
||||
export function resolveSampleProgressAggregateStatus(suppliers: SupplierStatusInput[]) {
|
||||
const active = suppliers.filter((supplier) => !isSampleProgressTerminalStatus(supplier.status))
|
||||
const normalized = suppliers.map((supplier) => ({ ...supplier, status: normalizeSampleProgressStatus(supplier.status) }))
|
||||
const active = normalized.filter((supplier) => !isSampleProgressTerminalStatus(supplier.status))
|
||||
if (active.length > 0) {
|
||||
return active.reduce((slowest, supplier) =>
|
||||
(SAMPLE_PROGRESS_ACTIVE_STATUS_ORDER.get(supplier.status) ?? Number.MAX_SAFE_INTEGER)
|
||||
@ -91,13 +105,12 @@ export function resolveSampleProgressAggregateStatus(suppliers: SupplierStatusIn
|
||||
: slowest,
|
||||
).status
|
||||
}
|
||||
if (suppliers.some((supplier) => supplier.status === 'COMPLETED')) return 'COMPLETED'
|
||||
if (suppliers.every((supplier) => supplier.status === 'CANCELLED')) return 'CANCELLED'
|
||||
if (normalized.length > 0 && normalized.every((supplier) => supplier.status === 'COMPLETED')) return 'COMPLETED'
|
||||
return 'TERMINATED'
|
||||
}
|
||||
|
||||
export function resolveSampleProgressAggregateEndedAt(suppliers: SupplierStatusInput[]) {
|
||||
if (suppliers.length === 0 || suppliers.some((supplier) => !isSampleProgressTerminalStatus(supplier.status))) return null
|
||||
if (suppliers.length === 0 || suppliers.some((supplier) => normalizeSampleProgressStatus(supplier.status) !== 'COMPLETED')) return null
|
||||
const endedTimes = suppliers
|
||||
.map((supplier) => supplier.actualSampleCompletedAt ? new Date(supplier.actualSampleCompletedAt).getTime() : Number.NaN)
|
||||
.filter(Number.isFinite)
|
||||
@ -133,7 +146,7 @@ export type SampleProgressAnomaly = keyof typeof SAMPLE_PROGRESS_ANOMALY_LABELS
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
export function calculateSampleProgressAnomaly(input: { status: string; oaReachedAt: Date | string | null; sampleDueDate: Date | string; now?: Date }): SampleProgressAnomaly {
|
||||
if (isSampleProgressTerminalStatus(input.status)) return 'NONE'
|
||||
if (isSampleProgressTerminalStatus(normalizeSampleProgressStatus(input.status))) return 'NONE'
|
||||
const now = input.now ?? new Date()
|
||||
const dueAt = new Date(input.sampleDueDate)
|
||||
if (now.getTime() > dueAt.getTime() + DAY_MS - 1) return 'FINISHED_PRODUCT'
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
storeAccount: {
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { StoreAccountManagementRepository } from '../store-account-management.repository'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
storeAccount: { findMany: jest.Mock; count: jest.Mock }
|
||||
}
|
||||
|
||||
describe('StoreAccountManagementRepository list ordering', () => {
|
||||
beforeEach(() => jest.clearAllMocks())
|
||||
|
||||
it('returns the newest store accounts first with a stable id tie-breaker', async () => {
|
||||
mockPrisma.storeAccount.findMany.mockResolvedValue([])
|
||||
mockPrisma.storeAccount.count.mockResolvedValue(0)
|
||||
|
||||
await expect(new StoreAccountManagementRepository().listPage({
|
||||
skip: 0,
|
||||
take: 50,
|
||||
filters: {
|
||||
search: '',
|
||||
storeName: '',
|
||||
accountType: '',
|
||||
operationOwner: '',
|
||||
accountGroupName: '',
|
||||
},
|
||||
})).resolves.toEqual({ rows: [], total: 0 })
|
||||
|
||||
expect(mockPrisma.storeAccount.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
skip: 0,
|
||||
take: 50,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -125,7 +125,7 @@ export class StoreAccountManagementRepository {
|
||||
async listPage(params: { skip: number; take: number; filters: StoreAccountFilters }) {
|
||||
const where = buildWhere(params.filters)
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.storeAccount.findMany({ where, select: ACCOUNT_SELECT, orderBy: [{ storeName: 'asc' }, { account: 'asc' }], skip: params.skip, take: params.take }),
|
||||
prisma.storeAccount.findMany({ where, select: ACCOUNT_SELECT, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip: params.skip, take: params.take }),
|
||||
prisma.storeAccount.count({ where }),
|
||||
])
|
||||
return { rows, total }
|
||||
@ -157,11 +157,9 @@ export class StoreAccountManagementRepository {
|
||||
operationDepartmentHead?: string
|
||||
operationDirector?: string
|
||||
profitCenter?: string
|
||||
subject?: string
|
||||
accountType: string
|
||||
account: string
|
||||
passwordCiphertext?: string
|
||||
boundBankAccountNumberCiphertext?: string
|
||||
boundPhone?: string
|
||||
verificationContact?: string
|
||||
jstOrderService?: string
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
const mockGetUserAccess = jest.fn()
|
||||
const mockResolveObjectStorageUrlForClientAccess = jest.fn()
|
||||
|
||||
jest.mock('@/server/services/supply-chain-permission-settings.service', () => ({
|
||||
supplyChainPermissionSettingsService: {
|
||||
getUserAccess: (...args: unknown[]) => mockGetUserAccess(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/storage', () => ({
|
||||
resolveObjectStorageUrlForClientAccess: (...args: unknown[]) => mockResolveObjectStorageUrlForClientAccess(...args),
|
||||
}))
|
||||
|
||||
import type { FinishedProductProgressRepository, FinishedProductProgressRecord } from '@/server/repositories/finished-product-progress.repository'
|
||||
import { FinishedProductProgressService } from '@/server/services/finished-product-progress.service'
|
||||
|
||||
const user = { id: 'viewer-1', name: '只读用户', role: 'USER' as const }
|
||||
const signedImageUrl = 'https://qnycdn.jiansun.vip/prod/finished-product-progress/import-images/example.png?e=1787996012&token=expired'
|
||||
const stableImageUrl = '/api/storage/object?key=prod%2Ffinished-product-progress%2Fimport-images%2Fexample.png'
|
||||
|
||||
function record(): FinishedProductProgressRecord {
|
||||
const timestamp = new Date('2026-08-29T08:32:22.000Z')
|
||||
return {
|
||||
id: 'finished-1',
|
||||
contractDateNumber: '260829001',
|
||||
contractNumber: 'LK20260829001',
|
||||
imageUrl: signedImageUrl,
|
||||
itemNumber: 'ITEM-1',
|
||||
barcode: null,
|
||||
productName: '测试产品',
|
||||
supplier: '测试供应商',
|
||||
brand: '测试品牌',
|
||||
orderedQuantity: 100,
|
||||
inTransitQuantity: 100,
|
||||
orderDueDate: null,
|
||||
realtimeDate: null,
|
||||
deliveryQuantity: 0,
|
||||
currentNode: '待送货',
|
||||
completedAt: null,
|
||||
remarks: '',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
updatedByName: '导入人',
|
||||
deliveryRecords: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('FinishedProductProgressService shared image access', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockGetUserAccess.mockResolvedValue({ view: true, edit: false, download: false })
|
||||
mockResolveObjectStorageUrlForClientAccess.mockReturnValue(stableImageUrl)
|
||||
})
|
||||
|
||||
it('normalizes historical signed image URLs before returning server-rendered rows', async () => {
|
||||
const repository = {
|
||||
listPage: jest.fn().mockResolvedValue({ groups: [[record()]], total: 1 }),
|
||||
listOptions: jest.fn().mockResolvedValue({ suppliers: [], brands: [], currentNodes: [] }),
|
||||
} as unknown as FinishedProductProgressRepository
|
||||
const service = new FinishedProductProgressService(repository)
|
||||
|
||||
const result = await service.listPage({ page: 1, pageSize: 50, user })
|
||||
|
||||
expect(mockResolveObjectStorageUrlForClientAccess).toHaveBeenCalledWith(signedImageUrl)
|
||||
expect(result.rows[0]?.details[0]?.imageUrl).toBe(stableImageUrl)
|
||||
})
|
||||
})
|
||||
@ -12,6 +12,7 @@ import {
|
||||
type FinishedProductProgressSummaryRow,
|
||||
type FinishedProductDeliveryChangeFieldKey,
|
||||
} from '@/lib/finished-product-progress'
|
||||
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import {
|
||||
@ -83,7 +84,7 @@ function toDetailRow(record: FinishedProductProgressRecord): FinishedProductProg
|
||||
completedAt: dateTimeText(record.completedAt),
|
||||
contractDateNumber: record.contractDateNumber,
|
||||
contractNumber: record.contractNumber,
|
||||
imageUrl: record.imageUrl ?? '',
|
||||
imageUrl: record.imageUrl ? resolveObjectStorageUrlForClientAccess(record.imageUrl) : '',
|
||||
itemNumber: record.itemNumber,
|
||||
barcode: record.barcode ?? '',
|
||||
productName: record.productName,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { SampleProgressSupplier } from '@prisma/client'
|
||||
import { calculateSampleProgressAnomaly } from '@/lib/sample-progress'
|
||||
import { calculateSampleProgressAnomaly, normalizeSampleProgressStatus } from '@/lib/sample-progress'
|
||||
import { sampleProgressRepository } from '@/server/repositories/sample-progress.repository'
|
||||
import {
|
||||
buildSampleProgressParentSummary,
|
||||
@ -38,13 +38,17 @@ export function serializeSampleProgress(item: SampleProgressRecord) {
|
||||
sampleNotifiedAt: supplier.sampleNotifiedAt,
|
||||
sampleDueDate: supplier.sampleDueDate,
|
||||
courierTrackingNumber: supplier.courierTrackingNumber,
|
||||
status: supplier.status,
|
||||
status: normalizeSampleProgressStatus(supplier.status),
|
||||
requestedCost: supplier.requestedCost?.toNumber() ?? null,
|
||||
actualCost: supplier.actualCost?.toNumber() ?? null,
|
||||
progressDescription: supplier.progressDescription,
|
||||
actualSampleCompletedAt: supplier.actualSampleCompletedAt,
|
||||
}))
|
||||
const summary = buildSampleProgressParentSummary(supplierWrites)
|
||||
// OA synchronisation writes the parent completion timestamp. Keep that
|
||||
// value as a compatibility fallback while supplier rows created by the
|
||||
// multi-supplier migration have not yet received their own timestamp.
|
||||
const actualSampleCompletedAt = summary.actualSampleCompletedAt ?? item.actualSampleCompletedAt
|
||||
const anomalyPriority = { NONE: 0, BARE_WOOD: 1, PAINT: 2, FINISHED_PRODUCT: 3 } as const
|
||||
const anomaly = supplierWrites
|
||||
.map((supplier) => calculateSampleProgressAnomaly({ ...supplier, oaReachedAt: item.oaReachedAt }))
|
||||
@ -62,7 +66,7 @@ export function serializeSampleProgress(item: SampleProgressRecord) {
|
||||
})),
|
||||
orderDate: item.orderDate.toISOString(),
|
||||
sampleDueDate: summary.sampleDueDate.toISOString(),
|
||||
actualSampleCompletedAt: summary.actualSampleCompletedAt?.toISOString() ?? null,
|
||||
actualSampleCompletedAt: actualSampleCompletedAt?.toISOString() ?? null,
|
||||
oaReachedAt: item.oaReachedAt?.toISOString() ?? null,
|
||||
supplierConfirmedAt: summary.supplierConfirmedAt?.toISOString() ?? null,
|
||||
sampleNotifiedAt: summary.sampleNotifiedAt?.toISOString() ?? null,
|
||||
|
||||
@ -44,9 +44,9 @@ describe('sample progress supplier parent summary', () => {
|
||||
const now = new Date('2026-08-27T08:00:00.000Z')
|
||||
const suppliers = resolveSupplierWrites([
|
||||
{ supplier: 'A', sampleDueDate: now, status: 'COMPLETED' },
|
||||
{ supplier: 'B', sampleDueDate: now, status: 'CANCELLED' },
|
||||
{ supplier: 'B', sampleDueDate: now, status: 'TERMINATED' },
|
||||
], [], now)
|
||||
|
||||
expect(buildSampleProgressParentSummary(suppliers).actualSampleCompletedAt).toEqual(now)
|
||||
expect(buildSampleProgressParentSummary(suppliers).actualSampleCompletedAt).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
type SampleProgressInput,
|
||||
type SampleProgressSupplierInput,
|
||||
type SampleProgressUpdateInput,
|
||||
normalizeSampleProgressStatus,
|
||||
} from '@/lib/sample-progress'
|
||||
|
||||
export type StoredSampleProgressSupplier = {
|
||||
@ -48,7 +49,7 @@ export function legacySupplierInput(input: SampleProgressInput): SampleProgressS
|
||||
sampleNotifiedAt: input.sampleNotifiedAt,
|
||||
sampleDueDate: input.sampleDueDate,
|
||||
courierTrackingNumber: input.courierTrackingNumber,
|
||||
status: input.status,
|
||||
status: normalizeSampleProgressStatus(input.status) as SampleProgressSupplierInput['status'],
|
||||
requestedCost: input.requestedCost,
|
||||
actualCost: input.actualCost,
|
||||
progressDescription: input.progressDescription,
|
||||
@ -66,7 +67,7 @@ export function storedSupplierAsInput(supplier: StoredSampleProgressSupplier): S
|
||||
sampleNotifiedAt: supplier.sampleNotifiedAt,
|
||||
sampleDueDate: supplier.sampleDueDate,
|
||||
courierTrackingNumber: supplier.courierTrackingNumber,
|
||||
status: supplier.status as SampleProgressSupplierInput['status'],
|
||||
status: normalizeSampleProgressStatus(supplier.status) as SampleProgressSupplierInput['status'],
|
||||
requestedCost: numberValue(supplier.requestedCost),
|
||||
actualCost: numberValue(supplier.actualCost),
|
||||
progressDescription: supplier.progressDescription,
|
||||
@ -99,7 +100,7 @@ export function resolveSupplierWrites(
|
||||
sampleNotifiedAt: supplier.sampleNotifiedAt ?? null,
|
||||
sampleDueDate: supplier.sampleDueDate,
|
||||
courierTrackingNumber: supplier.courierTrackingNumber?.trim() || null,
|
||||
status: supplier.status,
|
||||
status: normalizeSampleProgressStatus(supplier.status),
|
||||
requestedCost: supplier.requestedCost ?? null,
|
||||
actualCost: supplier.actualCost ?? null,
|
||||
progressDescription: supplier.progressDescription?.trim() || null,
|
||||
@ -119,7 +120,7 @@ function sumNullable(values: Array<number | null>) {
|
||||
}
|
||||
|
||||
function earliestDueDate(suppliers: SampleProgressSupplierWrite[]) {
|
||||
const active = suppliers.filter((supplier) => !['COMPLETED', 'CANCELLED', 'TERMINATED'].includes(supplier.status))
|
||||
const active = suppliers.filter((supplier) => !['COMPLETED', 'TERMINATED'].includes(normalizeSampleProgressStatus(supplier.status)))
|
||||
const candidates = active.length > 0 ? active : suppliers
|
||||
return new Date(Math.min(...candidates.map((supplier) => supplier.sampleDueDate.getTime())))
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { sampleProgressRepository } from '@/server/repositories/sample-progress.repository'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { oaBoardService } from '@/server/services/oa-board.service'
|
||||
import { sampleProgressService } from './sample-progress.service'
|
||||
import { actualSampleCompletedAtFromOa, sampleProgressService } from './sample-progress.service'
|
||||
|
||||
jest.mock('@/server/repositories/sample-progress.repository', () => ({
|
||||
sampleProgressRepository: {
|
||||
@ -180,3 +180,44 @@ describe('sampleProgressService.update', () => {
|
||||
expect(sampleProgressRepository.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('actualSampleCompletedAtFromOa', () => {
|
||||
const baseItem = {
|
||||
formSummary: { totalFlowStatusLabel: '进行中' },
|
||||
overallStatus: 'RUNNING',
|
||||
finishedDate: null,
|
||||
nodeAssessments: [],
|
||||
}
|
||||
|
||||
it('uses the physical-review completion time first', () => {
|
||||
expect(actualSampleCompletedAtFromOa({
|
||||
...baseItem,
|
||||
nodeAssessments: [{ nodeName: '实物审样', finishedAtSource: '2026-08-20T08:00:00.000Z', startedAtSource: '2026-08-19T08:00:00.000Z' }],
|
||||
} as never)).toEqual(new Date('2026-08-20T08:00:00.000Z'))
|
||||
})
|
||||
|
||||
it('falls back to the physical-review start time when no finish time exists', () => {
|
||||
expect(actualSampleCompletedAtFromOa({
|
||||
...baseItem,
|
||||
nodeAssessments: [{ nodeName: '实物审样', finishedAtSource: null, startedAtSource: '2026-08-19T08:00:00.000Z' }],
|
||||
} as never)).toEqual(new Date('2026-08-19T08:00:00.000Z'))
|
||||
})
|
||||
|
||||
it('uses overall completed time only when the flow is completed', () => {
|
||||
expect(actualSampleCompletedAtFromOa({
|
||||
...baseItem,
|
||||
overallStatus: 'COMPLETED',
|
||||
formSummary: { totalFlowStatusLabel: '已完结' },
|
||||
finishedDate: '2026-08-21T08:00:00.000Z',
|
||||
} as never)).toEqual(new Date('2026-08-21T08:00:00.000Z'))
|
||||
})
|
||||
|
||||
it('does not treat terminated flows as completed', () => {
|
||||
expect(actualSampleCompletedAtFromOa({
|
||||
...baseItem,
|
||||
overallStatus: 'TERMINATED',
|
||||
formSummary: { totalFlowStatusLabel: '已终止' },
|
||||
finishedDate: '2026-08-21T08:00:00.000Z',
|
||||
} as never)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -89,12 +89,29 @@ function sampleArrival(item: OaBoardProjectListItemDTO) {
|
||||
.sort()[0] ?? null
|
||||
}
|
||||
|
||||
function actualSampleCompletedAtFromOa(item: OaBoardProjectListItemDTO) {
|
||||
return item.nodeAssessments
|
||||
.filter((node) => node.nodeName === '填写样品快递单号' && node.finishedAtSource)
|
||||
.map((node) => new Date(node.finishedAtSource as string))
|
||||
export function actualSampleCompletedAtFromOa(item: OaBoardProjectListItemDTO) {
|
||||
const flowStatus = item.formSummary.totalFlowStatusLabel.trim()
|
||||
const terminated = ['已撤销', '已终止', '已拒绝'].includes(flowStatus)
|
||||
|| ['TERMINATED', 'CANCELED', 'CANCELLED', 'REFUSED'].includes(item.overallStatus || '')
|
||||
if (terminated) return null
|
||||
|
||||
const physicalReviewNodes = item.nodeAssessments.filter((node) => node.nodeName.trim().includes('实物审样'))
|
||||
const latestValidDate = (values: Array<string | null>) => values
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => new Date(value))
|
||||
.filter((value) => !Number.isNaN(value.getTime()))
|
||||
.sort((left, right) => right.getTime() - left.getTime())[0] ?? null
|
||||
const physicalReviewFinishedAt = latestValidDate(physicalReviewNodes.map((node) => node.finishedAtSource))
|
||||
if (physicalReviewFinishedAt) return physicalReviewFinishedAt
|
||||
const physicalReviewStartedAt = latestValidDate(physicalReviewNodes.map((node) => node.startedAtSource))
|
||||
if (physicalReviewStartedAt) return physicalReviewStartedAt
|
||||
|
||||
const overallCompleted = flowStatus === '已完结' || item.overallStatus === 'COMPLETED'
|
||||
if (overallCompleted && item.finishedDate) {
|
||||
const finishedAt = new Date(item.finishedDate)
|
||||
if (!Number.isNaN(finishedAt.getTime())) return finishedAt
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function sampleTimelineMetric(item: OaBoardProjectListItemDTO) {
|
||||
@ -103,16 +120,13 @@ function sampleTimelineMetric(item: OaBoardProjectListItemDTO) {
|
||||
|
||||
function sampleStatusFromFlow(item: OaBoardProjectListItemDTO, currentStatus?: string) {
|
||||
const flowStatus = item.formSummary.totalFlowStatusLabel.trim()
|
||||
// Sampling completion is defined by the courier-number node, not by the
|
||||
// overall product-development flow. A later overall termination must not
|
||||
// erase a sampling completion that already happened.
|
||||
if (actualSampleCompletedAtFromOa(item)) return 'COMPLETED'
|
||||
if (flowStatus === '已撤销') return 'CANCELLED'
|
||||
if (flowStatus === '已终止' || flowStatus === '已拒绝') return 'TERMINATED'
|
||||
if (flowStatus === '已撤销' || flowStatus === '已终止' || flowStatus === '已拒绝') return 'TERMINATED'
|
||||
if (['TERMINATED', 'CANCELED', 'CANCELLED', 'REFUSED'].includes(item.overallStatus || '')) return 'TERMINATED'
|
||||
if (actualSampleCompletedAtFromOa(item) || flowStatus === '已完结' || item.overallStatus === 'COMPLETED') return 'COMPLETED'
|
||||
if (currentStatus === 'COMPLETED') return 'BARE_WOOD'
|
||||
if (flowStatus === '进行中' && !sampleArrival(item)) return undefined
|
||||
// Leaving the overall "产品打样" stage does not prove that the courier
|
||||
// number node was completed. Keep the row in progress until that node has
|
||||
// Leaving the overall "产品打样" stage does not prove that the physical
|
||||
// review node was completed. Keep the row in progress until that node has
|
||||
// an actual completion timestamp.
|
||||
if (flowStatus === '进行中' && !isCurrentSampleStage(item)) {
|
||||
return currentStatus === 'COMPLETED' ? 'BARE_WOOD' : undefined
|
||||
@ -224,8 +238,7 @@ async function syncFromOaItems(items: OaBoardProjectListItemDTO[]) {
|
||||
const endedAtFromOa = actualSampleCompletedAtFromOa(item)
|
||||
const sampleMetric = sampleTimelineMetric(item)
|
||||
// Do not synthesize a completion timestamp when the overall flow is
|
||||
// terminated. The table is the sampling source of truth, so this field
|
||||
// must only come from the courier-number node.
|
||||
// terminated. Completion comes from physical review or overall completion.
|
||||
const endedAt = endedAtFromOa ?? (
|
||||
current?.autoGenerated ? null : current?.actualSampleCompletedAt ?? null
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user