feat(finished-product-progress): add contract group separators and alternating band backgrounds
This commit is contained in:
parent
138dc0d644
commit
43b867ef55
@ -43,6 +43,12 @@ const rows: FinishedProductProgressSummaryRow[] = [{
|
||||
isSummary: true,
|
||||
}]
|
||||
|
||||
const secondRow = { ...row, id: 'finished-2', contractDateNumber: '260902002', contractNumber: 'HT-002' }
|
||||
const groupedRows: FinishedProductProgressSummaryRow[] = [
|
||||
rows[0],
|
||||
{ id: 'group-2', contractDateNumber: secondRow.contractDateNumber, details: [secondRow], detailCount: 1, isSummary: true },
|
||||
]
|
||||
|
||||
describe('FinishedProductTable selection', () => {
|
||||
const renderTable = (overrides: Partial<ComponentProps<typeof FinishedProductTable>> = {}) => {
|
||||
const props: ComponentProps<typeof FinishedProductTable> = {
|
||||
@ -77,4 +83,13 @@ describe('FinishedProductTable selection', () => {
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: '全选当前页' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('marks each contract group with a strong separator and alternating band', () => {
|
||||
renderTable({ rows: groupedRows })
|
||||
|
||||
expect(screen.getByText('HT-001').closest('tr')).toHaveClass('finished-contract-start')
|
||||
expect(screen.getByText('HT-001').closest('td')).toHaveClass('finished-contract-band-a')
|
||||
expect(screen.getByText('HT-002').closest('tr')).toHaveClass('finished-contract-start')
|
||||
expect(screen.getByText('HT-002').closest('td')).toHaveClass('finished-contract-band-b')
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,29 +8,52 @@ import type { FinishedProductProgressColumn, FinishedProductProgressColumnKey }
|
||||
export function FinishedProductTable({ rows, columns, canEdit, selectedIds, allSelected, onToggleSelect, onToggleAll, onEdit, onView, onHistory, onPreview, onDelete }: { rows: FinishedProductProgressSummaryRow[]; columns: readonly FinishedProductProgressColumn[]; canEdit: boolean; selectedIds: ReadonlySet<string>; allSelected: boolean; onToggleSelect: (id: string) => void; onToggleAll: () => void; onEdit: (row: FinishedProductProgressRow) => void; onView: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void; onDelete: (row: FinishedProductProgressRow) => void }) {
|
||||
const minWidth = columns.reduce((total, column) => total + column.minWidth, 0)
|
||||
const detailCount = rows.reduce((total, group) => total + group.details.length, 0)
|
||||
const tableRows = buildFinishedProductTableRows(rows)
|
||||
return <div className="min-h-0 flex-1 overflow-auto overscroll-contain">
|
||||
<table className="w-full border-separate border-spacing-0 text-center text-xs" style={{ minWidth: minWidth + (canEdit ? 44 : 0) }}>
|
||||
<thead className="text-[11px] font-bold text-zinc-600"><tr>{canEdit && <th aria-label="选择" style={{ width: 44, minWidth: 44, maxWidth: 44 }} className="sticky left-0 top-0 z-40 w-11 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-2 py-3"><input type="checkbox" checked={allSelected} disabled={detailCount === 0} onChange={onToggleAll} aria-label="全选当前页" /></th>}{columns.map((column) => <th key={column.key} style={{ minWidth: column.minWidth }} className={`sticky top-0 z-20 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-3 py-3 ${shiftStickyClass(column.stickyClassName, canEdit)}`}>{column.label}</th>)}</tr></thead>
|
||||
<tbody>{rows.flatMap((group) => group.details.map((row, index) => {
|
||||
const contractKey = row.contractNumber.trim() || row.id
|
||||
const isContractFirst = index === 0 || group.details[index - 1]?.contractNumber.trim() !== row.contractNumber.trim()
|
||||
const contractRunLength = isContractFirst
|
||||
? group.details.slice(index).findIndex((candidate, offset) => offset > 0 && candidate.contractNumber.trim() !== row.contractNumber.trim())
|
||||
: 0
|
||||
const contractRowSpan = isContractFirst ? (contractRunLength === -1 ? group.details.length - index : contractRunLength) : 0
|
||||
return <tr key={row.id} className="group hover:bg-zinc-50">{canEdit && isContractFirst && <td rowSpan={contractRowSpan} className="finished-product-select-cell sticky left-0 z-10 border-b border-zinc-100 bg-white px-2 py-3 align-middle group-hover:bg-zinc-50"><input type="checkbox" checked={selectedIds.has(contractKey)} onChange={() => onToggleSelect(contractKey)} aria-label={`选择合同号 ${row.contractNumber || '-'}`} /></td>}{columns.map((column) => column.key === 'contractDateNumber'
|
||||
<tbody>{tableRows.map(({ group, row, index, contractKey, isContractFirst, contractRowSpan, contractBandClass }) => {
|
||||
return <tr key={row.id} className={`group hover:bg-zinc-50 ${isContractFirst ? 'finished-contract-start' : ''}`}>{canEdit && isContractFirst && <td rowSpan={contractRowSpan} className={`finished-product-select-cell sticky left-0 z-10 border-b border-zinc-100 px-2 py-3 align-middle group-hover:bg-zinc-50 ${contractBandClass}`}><input type="checkbox" checked={selectedIds.has(contractKey)} onChange={() => onToggleSelect(contractKey)} aria-label={`选择合同号 ${row.contractNumber || '-'}`} /></td>}{columns.map((column) => column.key === 'contractDateNumber'
|
||||
? index === 0
|
||||
? <td key={column.key} rowSpan={group.details.length} className={`sticky ${canEdit ? 'left-[44px]' : '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>
|
||||
? <td key={column.key} rowSpan={group.details.length} className={`sticky ${canEdit ? 'left-[44px]' : 'left-0'} z-10 align-middle whitespace-nowrap border-b border-zinc-100 px-3 py-3 font-mono text-zinc-900 group-hover:bg-zinc-50 ${contractBandClass}`}>{group.contractDateNumber || '-'}</td>
|
||||
: null
|
||||
: <FinishedProductCell key={column.key} column={column.key} row={row} canEdit={canEdit} onEdit={onEdit} onView={onView} onHistory={onHistory} onPreview={onPreview} onDelete={onDelete} />)}</tr>
|
||||
}))}</tbody>
|
||||
: <FinishedProductCell key={column.key} column={column.key} row={row} canEdit={canEdit} bandClass={contractBandClass} onEdit={onEdit} onView={onView} onHistory={onHistory} onPreview={onPreview} onDelete={onDelete} />)}</tr>
|
||||
})}</tbody>
|
||||
</table>
|
||||
{detailCount === 0 && <div className="py-20 text-center text-sm text-zinc-500">暂无匹配的成品进度记录</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function FinishedProductCell({ column, row, canEdit, onEdit, onView, onHistory, onPreview, onDelete }: { column: FinishedProductProgressColumnKey; row: FinishedProductProgressDetailRow; canEdit: boolean; onEdit: (row: FinishedProductProgressRow) => void; onView: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void; onDelete: (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'
|
||||
function buildFinishedProductTableRows(rows: FinishedProductProgressSummaryRow[]) {
|
||||
let previousContractVisualKey: string | null = null
|
||||
let contractBandIndex = -1
|
||||
return rows.flatMap((group) => group.details.map((row, index) => {
|
||||
const contractNumber = row.contractNumber.trim()
|
||||
const contractKey = contractNumber || row.id
|
||||
// Summary groups are already separated by date number; keep their visual bands
|
||||
// distinct even when a legacy import happens to reuse the same contract number.
|
||||
const contractVisualKey = `${group.id}:${contractNumber || '__blank__'}`
|
||||
const isContractFirst = contractVisualKey !== previousContractVisualKey
|
||||
if (isContractFirst) contractBandIndex += 1
|
||||
previousContractVisualKey = contractVisualKey
|
||||
const contractRunLength = isContractFirst
|
||||
? group.details.slice(index).findIndex((candidate, offset) => offset > 0 && candidate.contractNumber.trim() !== row.contractNumber.trim())
|
||||
: 0
|
||||
const contractRowSpan = isContractFirst ? (contractRunLength === -1 ? group.details.length - index : contractRunLength) : 0
|
||||
return {
|
||||
group,
|
||||
row,
|
||||
index,
|
||||
contractKey,
|
||||
isContractFirst,
|
||||
contractRowSpan,
|
||||
contractBandClass: contractBandIndex % 2 === 0 ? 'finished-contract-band-a' : 'finished-contract-band-b',
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function FinishedProductCell({ column, row, canEdit, bandClass, onEdit, onView, onHistory, onPreview, onDelete }: { column: FinishedProductProgressColumnKey; row: FinishedProductProgressDetailRow; canEdit: boolean; bandClass: string; onEdit: (row: FinishedProductProgressRow) => void; onView: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void; onDelete: (row: FinishedProductProgressRow) => void }) {
|
||||
const base = `whitespace-nowrap border-b border-zinc-100 px-3 py-3 text-zinc-700 group-hover:bg-zinc-50 ${bandClass}`
|
||||
if (column === 'contractNumber') return <td className={`${base} sticky ${canEdit ? 'left-[154px]' : 'left-[110px]'} z-10 font-semibold text-zinc-900`}>{row.contractNumber || '-'}</td>
|
||||
if (column === 'followUpOwner') return <td className={`${base} sticky ${canEdit ? 'left-[304px]' : 'left-[260px]'} z-10 font-medium text-zinc-900`}>{row.followUpOwner || '-'}</td>
|
||||
if (column === 'imageUrl') return <td className={`${base} sticky ${canEdit ? 'left-[424px]' : 'left-[380px]'} 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>
|
||||
|
||||
@ -14,7 +14,7 @@ export const finishedProductProgressStyles = String.raw`
|
||||
.finished-filter-multi{position:relative;min-width:0}.finished-filter-multi>button{display:flex;height:38px;width:100%;align-items:center;justify-content:space-between;gap:8px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;padding:0 11px;color:#3f3f46;font-size:12px}.finished-filter-multi>button:hover,.finished-filter-multi>button[aria-expanded=true]{border-color:#71717a}.finished-filter-multi-panel{position:absolute;left:0;top:43px;z-index:55;width:max(100%,320px);max-width:calc(100vw - 28px);overflow:hidden;border:1px solid #d4d4d8;border-radius:6px;background:#fff;box-shadow:0 12px 28px rgba(24,24,27,.14)}.finished-filter-multi-header{display:flex;height:38px;align-items:center;justify-content:space-between;border-bottom:1px solid #e4e4e7;padding:0 10px;font-size:12px}.finished-filter-multi-header button{display:flex;height:25px;width:25px;align-items:center;justify-content:center;border:0;border-radius:4px;background:transparent;color:#71717a}.finished-filter-multi-header button:hover{background:#f4f4f5}.finished-filter-multi-search{display:flex;align-items:center;gap:7px;margin:7px 7px 0;height:34px;padding:0 9px;border:1px solid #d4d4d8;border-radius:4px;color:#71717a}.finished-filter-multi-search input{height:30px;min-width:0;flex:1;border:0!important;box-shadow:none!important;padding:0!important;outline:none}.finished-filter-multi-options{max-height:280px;overflow-y:auto;padding:6px}.finished-filter-multi-options label{display:grid;grid-template-columns:18px minmax(0,1fr);align-items:center;gap:7px;min-height:34px;border-radius:4px;padding:4px 7px;color:#3f3f46;font-size:12px;cursor:pointer}.finished-filter-multi-options label:hover{background:#f4f4f5}.finished-filter-multi-options input{position:absolute;height:0;width:0;opacity:0}.finished-filter-multi-check{display:flex;height:16px;width:16px;align-items:center;justify-content:center;border:1px solid #d4d4d8;border-radius:3px}.finished-filter-multi-options input:checked+.finished-filter-multi-check{border-color:#27272a;background:#27272a;color:#fff}.finished-filter-multi-options label>span:last-child{min-width:0;overflow-wrap:anywhere;white-space:normal;line-height:1.45}.finished-filter-multi-empty{display:block;padding:18px;text-align:center;color:#a1a1aa;font-size:12px}
|
||||
.finished-product-table-shell{position:relative;display:flex;min-height:0;flex:1;flex-direction:column;margin:16px 20px 20px;overflow:hidden;border:1px solid #e4e4e7;border-radius:7px;background:#fff;box-shadow:0 8px 24px rgba(24,24,27,.05)}
|
||||
.finished-product-table-shell>div:first-child{padding-bottom:76px}
|
||||
.finished-field{display:flex;flex-direction:column;gap:6px}.finished-field>span{font-size:12px;font-weight:650;color:#52525b}.finished-field em{margin-left:3px;color:#b91c1c;font-style:normal}.finished-field input,.finished-field textarea{width:100%;box-sizing:border-box;border:1px solid #d4d4d8;border-radius:5px;background:#fff;padding:9px 10px;color:#27272a;font-size:13px;outline:none}.finished-field input{height:40px}.finished-field textarea{resize:vertical;line-height:1.6}.finished-field input:focus,.finished-field textarea:focus{border-color:#71717a;box-shadow:0 0 0 2px rgba(82,82,91,.08)}.finished-product-select-cell{width:44px;min-width:44px}.finished-product-select-cell input{height:16px;width:16px;accent-color:#27272a;cursor:pointer}
|
||||
.finished-field{display:flex;flex-direction:column;gap:6px}.finished-field>span{font-size:12px;font-weight:650;color:#52525b}.finished-field em{margin-left:3px;color:#b91c1c;font-style:normal}.finished-field input,.finished-field textarea{width:100%;box-sizing:border-box;border:1px solid #d4d4d8;border-radius:5px;background:#fff;padding:9px 10px;color:#27272a;font-size:13px;outline:none}.finished-field input{height:40px}.finished-field textarea{resize:vertical;line-height:1.6}.finished-field input:focus,.finished-field textarea:focus{border-color:#71717a;box-shadow:0 0 0 2px rgba(82,82,91,.08)}.finished-product-select-cell{width:44px;min-width:44px}.finished-product-select-cell input{height:16px;width:16px;accent-color:#27272a;cursor:pointer}.finished-contract-band-a{background:#fff}.finished-contract-band-b{background:#f5f7f7}.finished-contract-start>td{border-top:2px solid #52525b}
|
||||
@media(max-width:1280px){.finished-product-filters{grid-template-columns:repeat(4,minmax(140px,1fr))}.finished-product-filters>input:first-child{grid-column:span 2}}
|
||||
@media(max-width:760px){.finished-product-toolbar{align-items:flex-start;padding:14px}.finished-product-actions{justify-content:flex-start}.finished-product-filters{grid-template-columns:1fr 1fr;padding:10px 14px}.finished-product-filters>input:first-child{grid-column:1/-1}.finished-product-table-shell{margin:10px}.finished-product-table-shell>div:first-child{padding-bottom:72px}.finished-page-button{padding:0 7px}.finished-product-toolbar p{display:none}}
|
||||
`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user