feat(sample-progress): enrich OA tracking board

This commit is contained in:
jingchuan 2026-08-12 11:07:49 +08:00
parent 944b5b0a58
commit 432cd9fc4f
6 changed files with 87 additions and 9 deletions

View File

@ -0,0 +1,7 @@
ALTER TABLE "SampleProgressItem"
ADD COLUMN "flowStatus" TEXT NOT NULL DEFAULT '';
UPDATE "SampleProgressItem"
SET "progressDescription" = NULL
WHERE "autoGenerated" = true
AND "progressDescription" = 'OA 自动生成,待补充打样信息';

View File

@ -25,6 +25,7 @@ model SampleProgressItem {
supplier String
finalSampleFactory String?
status String
flowStatus String @default("")
designer String
purchaseOwner String?
merchandiserOwner String?

View File

@ -378,7 +378,7 @@ const WORKBENCH_FILTER_CONTROL_CLASS =
const WORKBENCH_SIDE_SECTION_CLASS =
'rounded-[1.4rem] border border-apple-separator bg-white/92 shadow-apple-xs'
const WORKBENCH_SIDE_BUTTON_BASE_CLASS =
'flex h-[54px] w-full min-w-0 items-center justify-between rounded-apple-lg border px-4 py-2 text-sm font-apple-medium transition'
'flex h-[54px] w-full min-w-0 items-center justify-between rounded-apple-lg border px-3 py-2 text-[13px] font-apple-medium transition'
const WORKBENCH_SIDE_BUTTON_ACTIVE_CLASS =
'border-zinc-950 bg-zinc-950 text-white shadow-none'
const WORKBENCH_SIDE_BUTTON_DEFAULT_CLASS =
@ -4541,7 +4541,7 @@ export default function OaBoardClient({
: WORKBENCH_SIDE_BUTTON_DEFAULT_CLASS,
].join(' ')}
>
<span className="min-w-0 flex-1 text-left leading-5">{item.label}</span>
<span className="min-w-0 flex-1 whitespace-nowrap text-left leading-5">{item.label}</span>
{item.key === 'shipment' || item.key === 'listing' ? (
<span className="rounded-full bg-black/6 px-2 py-0.5 text-[10px] font-medium">
预留

View File

@ -37,6 +37,7 @@ export default function SampleProgressClient({ initialItems, currentUserName, em
const approvedCount = items.filter((item) => item.status === 'COMPLETED').length
const requestedTotal = items.reduce((sum, item) => sum + (item.requestedCost ?? 0), 0)
const actualTotal = items.reduce((sum, item) => sum + (item.actualCost ?? 0), 0)
const statusCounts = SAMPLE_PROGRESS_STATUSES.map((value) => ({ value, count: items.filter((item) => item.status === value).length }))
async function remove(item: SampleProgressDTO) {
if (!window.confirm(`确认删除 ${item.openingCode} 的打样记录?`)) return
@ -59,6 +60,15 @@ export default function SampleProgressClient({ initialItems, currentUserName, em
<Metric icon={<CircleDollarSign size={18}/>} label="成本差异" value={money(actualTotal - requestedTotal)} tone={actualTotal > requestedTotal ? 'danger' : 'plain'} />
</section>
<section className="grid border-b border-black/[0.06] bg-white sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-7">
{statusCounts.map(({ value, count }) => (
<button key={value} type="button" onClick={() => { setStatus(value); setPage(1) }} className={`h-[68px] border-b border-r border-black/[0.05] px-4 text-left transition hover:bg-[#f7f9f8] ${status === 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>
))}
</section>
<section className="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 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-sm outline-none focus:border-[#26756b]"/></label>
@ -66,11 +76,11 @@ export default function SampleProgressClient({ initialItems, currentUserName, em
</div>
<div className="overflow-hidden rounded-[6px] border border-black/[0.08] bg-white shadow-[0_8px_24px_rgba(21,31,33,0.05)]">
<div className="overflow-x-auto"><table className="w-full min-w-[1900px] border-collapse text-left text-[13px]">
<thead className="bg-[#eef0ef] text-[11px] font-bold text-[#657074]"><tr>{['图片','开款编号','结构设计师','流程链接','异常项','OA流程到达时间','下单时间','打样交期','产品尺寸','颜色 / 材质','数量','计划售卖品牌','采购负责人','跟单负责人','供应商','最终出样厂家','打样状态','进度说明','需求成本','实际成本','操作'].map((label) => <th key={label} className="whitespace-nowrap border-b border-black/[0.07] px-3 py-3">{label}</th>)}</tr></thead>
<div className="overflow-x-auto"><table className="w-full min-w-[2000px] border-collapse text-left text-[13px]">
<thead className="bg-[#eef0ef] text-[11px] font-bold text-[#657074]"><tr>{['图片','开款编号','结构设计师','流程状态','流程链接','异常项','OA流程到达时间','下单时间','打样交期','产品尺寸','颜色 / 材质','数量','计划售卖品牌','采购负责人','跟单负责人','供应商','最终出样厂家','打样状态','进度说明','需求成本','实际成本','操作'].map((label) => <th key={label} className="whitespace-nowrap border-b border-black/[0.07] px-3 py-3">{label}</th>)}</tr></thead>
<tbody>{rows.map((item) => <tr key={item.id} className="group border-b border-black/[0.05] align-middle hover:bg-[#f8faf9]">
<td className="sticky left-0 z-10 bg-white p-2 group-hover:bg-[#f8faf9]">{item.imageUrl ? <Image unoptimized src={item.imageUrl} alt={item.openingCode} width={64} height={56} className="h-14 w-16 rounded-[4px] border border-black/[0.06] object-cover"/> : <div className="flex h-14 w-16 items-center justify-center rounded-[4px] bg-[#eef0ef] text-[#9ca3a5]"><ImageIcon size={18}/></div>}</td>
<td className="sticky left-[80px] z-10 bg-white px-3 font-black text-[#1e5d56] group-hover:bg-[#f8faf9]">{item.openingCode}</td><td className="px-3">{item.designer}</td>
<td className="sticky left-[80px] z-10 bg-white px-3 font-black text-[#1e5d56] group-hover:bg-[#f8faf9]">{item.openingCode}</td><td className="whitespace-nowrap px-3">{item.designer || '—'}</td><td className="whitespace-nowrap px-3">{item.flowStatus || '—'}</td>
<td className="px-3">{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>
<td className="px-3"><Anomaly value={item.anomaly}/></td><td className="whitespace-nowrap px-3 text-[#697174]">{item.oaReachedAt ? new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(item.oaReachedAt)) : '—'}</td><td className="px-3 text-[#697174]">{item.autoGenerated ? '待填写' : dateText(item.orderDate)}</td>
<td className={`px-3 font-bold ${isOverdue(item) ? 'text-[#b8423b]' : ''}`}>{item.autoGenerated ? '待填写' : dateText(item.sampleDueDate)}{isOverdue(item) && <span className="ml-1 text-[10px]">超期</span>}</td>

View File

@ -7,6 +7,24 @@ export const sampleProgressRepository = {
if (data.length === 0) return Promise.resolve({ count: 0 })
return prisma.sampleProgressItem.createMany({ data, skipDuplicates: true })
},
updateOaFields(input: { openingCode: string; designer: string; flowStatus: string; imageUrl: string | null; productDevelopmentFlowUrl: string | null }) {
return prisma.sampleProgressItem.updateMany({
where: { openingCode: input.openingCode },
data: {
designer: input.designer,
flowStatus: input.flowStatus,
imageUrl: input.imageUrl,
productDevelopmentFlowUrl: input.productDevelopmentFlowUrl,
},
})
},
findDesignSellingPointImages(productIds: string[]) {
if (productIds.length === 0) return Promise.resolve([])
return prisma.intakeNonIntegratedProduct.findMany({
where: { id: { in: productIds }, deletedAt: null },
select: { id: true, payload: true },
})
},
findById(id: string) { return prisma.sampleProgressItem.findUnique({ where: { id } }) },
create(data: Prisma.SampleProgressItemCreateInput) { return prisma.sampleProgressItem.create({ data }) },
update(id: string, data: Prisma.SampleProgressItemUpdateInput) { return prisma.sampleProgressItem.update({ where: { id }, data }) },

View File

@ -6,7 +6,7 @@ import { oaBoardService, type OaBoardProjectListItemDTO } from '@/server/service
function serialize(item: Awaited<ReturnType<typeof sampleProgressRepository.findById>> extends infer T ? NonNullable<T> : never) {
return { ...item, anomaly: item.autoGenerated ? 'NONE' as const : calculateSampleProgressAnomaly(item), orderDate: item.orderDate.toISOString(), sampleDueDate: item.sampleDueDate.toISOString(), oaReachedAt: item.oaReachedAt?.toISOString() ?? null, requestedCost: item.requestedCost?.toNumber() ?? null, actualCost: item.actualCost?.toNumber() ?? null, createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString() }
}
export type SampleProgressDTO = Omit<ReturnType<typeof serialize>, 'autoGenerated'> & { autoGenerated?: boolean }
export type SampleProgressDTO = Omit<ReturnType<typeof serialize>, 'autoGenerated' | 'flowStatus'> & { autoGenerated?: boolean; flowStatus?: string }
function exactOpeningCode(item: OaBoardProjectListItemDTO, openingCode: string) {
const expected = openingCode.trim().toUpperCase()
@ -65,7 +65,34 @@ function sampleArrival(item: OaBoardProjectListItemDTO) {
.sort()[0] ?? null
}
function linkedProductId(workflowLink: string) {
try {
const segments = new URL(workflowLink).pathname.split('/').filter(Boolean)
const index = segments.lastIndexOf('non-integrated')
return index >= 0 ? decodeURIComponent(segments[index + 1] || '') : ''
} catch {
return ''
}
}
function designSellingPointImage(payload: unknown): string | null {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
const files = (payload as { uploadFiles?: unknown }).uploadFiles
if (!Array.isArray(files)) return null
const file = files.find((value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const record = value as { category?: unknown; mimeType?: unknown; dataUrl?: unknown }
return record.category === '设计卖点' && String(record.mimeType || '').startsWith('image/') && typeof record.dataUrl === 'string' && record.dataUrl.trim()
}) as { dataUrl?: string } | undefined
return file?.dataUrl?.trim() || null
}
async function syncFromOaItems(items: OaBoardProjectListItemDTO[]) {
const productIds = Array.from(new Set(items.map((item) => linkedProductId(item.formSummary.workflowLink)).filter(Boolean)))
const products = await sampleProgressRepository.findDesignSellingPointImages(productIds)
const designImageByProductId = new Map<string, string | null>(
products.map((product): [string, string | null] => [product.id, designSellingPointImage(product.payload)]),
)
const rows = items.flatMap((item) => {
const openingCode = (item.formSummary.openingCode || item.openingCode || '').trim()
const reachedAt = sampleArrival(item)
@ -74,13 +101,15 @@ async function syncFromOaItems(items: OaBoardProjectListItemDTO[]) {
if (Number.isNaN(orderDate.getTime())) return []
const sampleDueDate = new Date(orderDate)
sampleDueDate.setDate(sampleDueDate.getDate() + 10)
const workflowLink = item.formSummary.workflowLink || ''
const imageUrl = designImageByProductId.get(linkedProductId(workflowLink)) || item.styleImages[0]?.url || null
return [{
openingCode,
orderDate,
sampleDueDate,
oaReachedAt: orderDate,
imageUrl: item.styleImages[0]?.url || null,
productDevelopmentFlowUrl: item.formSummary.workflowLink || null,
imageUrl,
productDevelopmentFlowUrl: workflowLink || null,
productSize: '',
color: '',
material: '',
@ -89,14 +118,27 @@ async function syncFromOaItems(items: OaBoardProjectListItemDTO[]) {
supplier: '',
finalSampleFactory: null,
status: 'BARE_WOOD',
flowStatus: item.formSummary.totalFlowStatusLabel || '',
designer: item.formSummary.structureDesigner || '',
purchaseOwner: item.formSummary.purchaseOwner || null,
merchandiserOwner: item.formSummary.merchandiserOwner || null,
progressDescription: 'OA 自动生成,待补充打样信息',
progressDescription: null,
autoGenerated: true,
}]
})
await sampleProgressRepository.createMany(rows)
await Promise.all(items.map((item) => {
const openingCode = (item.formSummary.openingCode || item.openingCode || '').trim()
if (!openingCode || !sampleArrival(item)) return Promise.resolve({ count: 0 })
const workflowLink = item.formSummary.workflowLink || ''
return sampleProgressRepository.updateOaFields({
openingCode,
designer: item.formSummary.structureDesigner || '',
flowStatus: item.formSummary.totalFlowStatusLabel || '',
imageUrl: designImageByProductId.get(linkedProductId(workflowLink)) || item.styleImages[0]?.url || null,
productDevelopmentFlowUrl: workflowLink || null,
})
}))
}
type SampleProgressActor = { id?: string; name?: string }