feat(sample-progress): enrich OA tracking board
This commit is contained in:
parent
944b5b0a58
commit
432cd9fc4f
@ -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 自动生成,待补充打样信息';
|
||||
@ -25,6 +25,7 @@ model SampleProgressItem {
|
||||
supplier String
|
||||
finalSampleFactory String?
|
||||
status String
|
||||
flowStatus String @default("")
|
||||
designer String
|
||||
purchaseOwner String?
|
||||
merchandiserOwner String?
|
||||
|
||||
@ -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">
|
||||
预留
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }) },
|
||||
|
||||
@ -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 }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user