fix(work-record): add resizable table columns
This commit is contained in:
parent
2e43240f51
commit
de2c1e1011
@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { SkuColumnResizableFrame } from "@/app/intake/non-integrated/components/SkuTableFields";
|
||||
import { WorkRecordPrimarySelect } from "@/app/intake/work-record/WorkRecordPrimarySelect";
|
||||
import {
|
||||
getWorkRecordRequiredFieldErrors,
|
||||
@ -9,6 +11,49 @@ import {
|
||||
} from "@/app/intake/work-record/workRecordValidation";
|
||||
import type { WorkRecordRow, WorkRecordScoreRule } from "@/lib/work-record";
|
||||
|
||||
const WORK_RECORD_COLUMN_DEFAULT_WIDTHS = {
|
||||
date: 128,
|
||||
designer: 112,
|
||||
primary: 144,
|
||||
secondary: 256,
|
||||
approvalOrTask: 224,
|
||||
spu: 128,
|
||||
quantity: 96,
|
||||
unitScore: 96,
|
||||
taskTotal: 112,
|
||||
fileStoragePath: 180,
|
||||
} as const;
|
||||
|
||||
const WORK_RECORD_ACTION_COLUMN_WIDTH = 48;
|
||||
|
||||
const WORK_RECORD_COLUMN_HEADERS: Array<{
|
||||
key: WorkRecordColumnWidthKey;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: "date", label: "日期" },
|
||||
{ key: "designer", label: "设计师" },
|
||||
{ key: "primary", label: "一级" },
|
||||
{ key: "secondary", label: "二级" },
|
||||
{ key: "approvalOrTask", label: "审批编号/具体事宜" },
|
||||
{ key: "spu", label: "SPU" },
|
||||
{ key: "quantity", label: "数量" },
|
||||
{ key: "unitScore", label: "单分值" },
|
||||
{ key: "taskTotal", label: "单任务合计" },
|
||||
{ key: "fileStoragePath", label: "文件储存路径" },
|
||||
];
|
||||
|
||||
type WorkRecordColumnWidthKey = keyof typeof WORK_RECORD_COLUMN_DEFAULT_WIDTHS;
|
||||
|
||||
function normalizeWorkRecordColumnWidth(
|
||||
column: WorkRecordColumnWidthKey,
|
||||
width: number,
|
||||
) {
|
||||
return Math.min(
|
||||
520,
|
||||
Math.max(WORK_RECORD_COLUMN_DEFAULT_WIDTHS[column], Math.round(width)),
|
||||
);
|
||||
}
|
||||
|
||||
function formatScore(value: number | null) {
|
||||
return value === null ? "" : String(value);
|
||||
}
|
||||
@ -32,6 +77,29 @@ function inputClassName(invalid: boolean) {
|
||||
}`;
|
||||
}
|
||||
|
||||
function WorkRecordColumnResizeFrame({
|
||||
children,
|
||||
column,
|
||||
columnWidths,
|
||||
onColumnResize,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
column: WorkRecordColumnWidthKey;
|
||||
columnWidths: Record<WorkRecordColumnWidthKey, number>;
|
||||
onColumnResize: (column: WorkRecordColumnWidthKey, width: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<SkuColumnResizableFrame
|
||||
className="my-2"
|
||||
columnWidth={columnWidths[column]}
|
||||
minWidth={WORK_RECORD_COLUMN_DEFAULT_WIDTHS[column]}
|
||||
onColumnResize={(width) => onColumnResize(column, width)}
|
||||
>
|
||||
{children}
|
||||
</SkuColumnResizableFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkRecordTable({
|
||||
canEditRecords,
|
||||
currentUserId,
|
||||
@ -50,22 +118,49 @@ export function WorkRecordTable({
|
||||
scoreRules: WorkRecordScoreRule[];
|
||||
}) {
|
||||
const primaryOptions = Array.from(new Set(scoreRules.map((rule) => rule.primary)));
|
||||
const [columnWidths, setColumnWidths] = useState<Record<WorkRecordColumnWidthKey, number>>(
|
||||
() => ({ ...WORK_RECORD_COLUMN_DEFAULT_WIDTHS }),
|
||||
);
|
||||
const tableWidth = WORK_RECORD_COLUMN_HEADERS.reduce(
|
||||
(total, column) => total + columnWidths[column.key],
|
||||
WORK_RECORD_ACTION_COLUMN_WIDTH,
|
||||
);
|
||||
|
||||
function handleColumnResize(column: WorkRecordColumnWidthKey, width: number) {
|
||||
setColumnWidths((current) => ({
|
||||
...current,
|
||||
[column]: normalizeWorkRecordColumnWidth(column, width),
|
||||
}));
|
||||
}
|
||||
|
||||
const columnResizeProps = {
|
||||
columnWidths,
|
||||
onColumnResize: handleColumnResize,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-[8px] border border-[var(--obs-shell-border)] bg-white">
|
||||
<table className="w-full min-w-[1380px] border-collapse text-xs">
|
||||
<table className="table-fixed border-collapse text-xs" style={{ width: tableWidth }}>
|
||||
<colgroup>
|
||||
{WORK_RECORD_COLUMN_HEADERS.map((column) => (
|
||||
<col key={column.key} style={{ width: columnWidths[column.key] }} />
|
||||
))}
|
||||
<col style={{ width: WORK_RECORD_ACTION_COLUMN_WIDTH }} />
|
||||
</colgroup>
|
||||
<thead className="bg-[var(--obs-shell-panel-bg)] text-[var(--obs-shell-title)]">
|
||||
<tr>
|
||||
<th className="w-32 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">日期</th>
|
||||
<th className="w-28 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">设计师</th>
|
||||
<th className="w-36 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">一级</th>
|
||||
<th className="w-64 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">二级</th>
|
||||
<th className="min-w-52 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">审批编号/具体事宜</th>
|
||||
<th className="w-32 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">SPU</th>
|
||||
<th className="w-24 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">数量</th>
|
||||
<th className="w-24 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">单分值</th>
|
||||
<th className="w-28 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">单任务合计</th>
|
||||
<th className="min-w-56 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">文件储存路径</th>
|
||||
{WORK_RECORD_COLUMN_HEADERS.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium"
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
<th
|
||||
aria-label="操作"
|
||||
className="border-b border-[var(--obs-shell-border)] p-0"
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -83,58 +178,62 @@ export function WorkRecordTable({
|
||||
|
||||
return (
|
||||
<tr key={record.id} className="border-b border-[var(--obs-shell-border)] last:border-b-0">
|
||||
<td className="px-2 py-2 align-top"><input aria-label="工作记录日期" readOnly value={record.date} className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-secondary outline-none" /></td>
|
||||
<td className="px-2 py-2 align-top"><input aria-label="工作记录设计师" readOnly value={record.designer} className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-secondary outline-none" /></td>
|
||||
<td className="px-2 py-2 align-top">
|
||||
<WorkRecordPrimarySelect
|
||||
disabled={!canEdit}
|
||||
options={primaryOptions}
|
||||
value={record.primary}
|
||||
onSelect={(primary) => {
|
||||
const rules = scoreRules.filter((rule) => rule.primary === primary);
|
||||
const onlyRule = rules.length === 1 ? rules[0] : null;
|
||||
const unitScore = onlyRule?.unitScore ?? null;
|
||||
<td className="align-top"><WorkRecordColumnResizeFrame column="date" {...columnResizeProps}><input aria-label="工作记录日期" readOnly value={record.date} className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-secondary outline-none" /></WorkRecordColumnResizeFrame></td>
|
||||
<td className="align-top"><WorkRecordColumnResizeFrame column="designer" {...columnResizeProps}><input aria-label="工作记录设计师" readOnly value={record.designer} className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-secondary outline-none" /></WorkRecordColumnResizeFrame></td>
|
||||
<td className="align-top">
|
||||
<WorkRecordColumnResizeFrame column="primary" {...columnResizeProps}>
|
||||
<WorkRecordPrimarySelect
|
||||
disabled={!canEdit}
|
||||
options={primaryOptions}
|
||||
value={record.primary}
|
||||
onSelect={(primary) => {
|
||||
const rules = scoreRules.filter((rule) => rule.primary === primary);
|
||||
const onlyRule = rules.length === 1 ? rules[0] : null;
|
||||
const unitScore = onlyRule?.unitScore ?? null;
|
||||
const nextRecord = onUpdateRecord(record.id, {
|
||||
primary,
|
||||
secondary: onlyRule?.secondary ?? "",
|
||||
scoreRuleId: onlyRule?.id ?? null,
|
||||
unitScore,
|
||||
taskTotal: calculateTaskTotal(record.quantity, unitScore),
|
||||
});
|
||||
commitIfReady(nextRecord);
|
||||
}}
|
||||
/>
|
||||
</WorkRecordColumnResizeFrame>
|
||||
</td>
|
||||
<td className="align-top">
|
||||
<WorkRecordColumnResizeFrame column="secondary" {...columnResizeProps}>
|
||||
<select aria-label="工作记录二级" value={record.scoreRuleId ?? ""} disabled={!canEdit || !record.primary} onChange={(event) => {
|
||||
const scoreRuleId = event.target.value || null;
|
||||
const rule = scoreRules.find((item) => item.id === scoreRuleId);
|
||||
const unitScore = rule?.unitScore ?? null;
|
||||
const nextRecord = onUpdateRecord(record.id, {
|
||||
primary,
|
||||
secondary: onlyRule?.secondary ?? "",
|
||||
scoreRuleId: onlyRule?.id ?? null,
|
||||
scoreRuleId,
|
||||
secondary: rule?.secondary ?? "",
|
||||
unitScore,
|
||||
taskTotal: calculateTaskTotal(record.quantity, unitScore),
|
||||
});
|
||||
commitIfReady(nextRecord);
|
||||
}}
|
||||
/>
|
||||
}} className="h-8 w-full rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:cursor-default disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary">
|
||||
<option value="" />
|
||||
{secondaryOptions.map((rule) => <option key={rule.id} value={rule.id}>{rule.secondary}</option>)}
|
||||
</select>
|
||||
</WorkRecordColumnResizeFrame>
|
||||
</td>
|
||||
<td className="px-2 py-2 align-top">
|
||||
<select aria-label="工作记录二级" value={record.scoreRuleId ?? ""} disabled={!canEdit || !record.primary} onChange={(event) => {
|
||||
const scoreRuleId = event.target.value || null;
|
||||
const rule = scoreRules.find((item) => item.id === scoreRuleId);
|
||||
const unitScore = rule?.unitScore ?? null;
|
||||
const nextRecord = onUpdateRecord(record.id, {
|
||||
scoreRuleId,
|
||||
secondary: rule?.secondary ?? "",
|
||||
unitScore,
|
||||
taskTotal: calculateTaskTotal(record.quantity, unitScore),
|
||||
});
|
||||
commitIfReady(nextRecord);
|
||||
}} className="h-8 w-full rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:cursor-default disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary">
|
||||
<option value="" />
|
||||
{secondaryOptions.map((rule) => <option key={rule.id} value={rule.id}>{rule.secondary}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-2 align-top">
|
||||
<input aria-label="审批编号或具体事宜" value={record.approvalOrTask} disabled={!canEdit} onChange={(event) => onUpdateRecord(record.id, { approvalOrTask: event.target.value })} onBlur={() => {
|
||||
<td className="align-top">
|
||||
<WorkRecordColumnResizeFrame column="approvalOrTask" {...columnResizeProps}><input aria-label="审批编号或具体事宜" value={record.approvalOrTask} disabled={!canEdit} onChange={(event) => onUpdateRecord(record.id, { approvalOrTask: event.target.value })} onBlur={() => {
|
||||
if (canCommitRecord(record)) onCommitRecord(record.id);
|
||||
}} className={inputClassName(Boolean(requiredErrors.approvalOrTask))} />
|
||||
{requiredErrors.approvalOrTask ? <p className="mt-1 text-[11px] text-red-600" role="alert">{requiredErrors.approvalOrTask}</p> : null}
|
||||
}} className={inputClassName(Boolean(requiredErrors.approvalOrTask))} /></WorkRecordColumnResizeFrame>
|
||||
{requiredErrors.approvalOrTask ? <p className="mb-2 px-2 text-[11px] text-red-600" role="alert">{requiredErrors.approvalOrTask}</p> : null}
|
||||
</td>
|
||||
<td className="px-2 py-2 align-top">
|
||||
<input aria-label="工作记录SPU" value={record.spu} disabled={!canEdit} onChange={(event) => onUpdateRecord(record.id, { spu: event.target.value })} onBlur={() => {
|
||||
<td className="align-top">
|
||||
<WorkRecordColumnResizeFrame column="spu" {...columnResizeProps}><input aria-label="工作记录SPU" value={record.spu} disabled={!canEdit} onChange={(event) => onUpdateRecord(record.id, { spu: event.target.value })} onBlur={() => {
|
||||
if (canCommitRecord(record)) onCommitRecord(record.id);
|
||||
}} className={inputClassName(Boolean(requiredErrors.spu))} />
|
||||
{requiredErrors.spu ? <p className="mt-1 text-[11px] text-red-600" role="alert">{requiredErrors.spu}</p> : null}
|
||||
}} className={inputClassName(Boolean(requiredErrors.spu))} /></WorkRecordColumnResizeFrame>
|
||||
{requiredErrors.spu ? <p className="mb-2 px-2 text-[11px] text-red-600" role="alert">{requiredErrors.spu}</p> : null}
|
||||
</td>
|
||||
<td className="px-2 py-2 align-top"><input aria-label="工作记录数量" type="number" min="1" step="1" value={record.quantity} disabled={!canEdit} onChange={(event) => {
|
||||
<td className="align-top"><WorkRecordColumnResizeFrame column="quantity" {...columnResizeProps}><input aria-label="工作记录数量" type="number" min="1" step="1" value={record.quantity} disabled={!canEdit} onChange={(event) => {
|
||||
const quantity = event.target.value;
|
||||
onUpdateRecord(record.id, {
|
||||
quantity,
|
||||
@ -142,10 +241,10 @@ export function WorkRecordTable({
|
||||
});
|
||||
}} onBlur={() => {
|
||||
if (canCommitRecord(record)) onCommitRecord(record.id);
|
||||
}} className={inputClassName(hasInvalidQuantity)} />
|
||||
{hasInvalidQuantity ? <p className="mt-1 text-[11px] text-red-600" role="alert">数量必须是正整数</p> : null}
|
||||
}} className={inputClassName(hasInvalidQuantity)} /></WorkRecordColumnResizeFrame>
|
||||
{hasInvalidQuantity ? <p className="mb-2 px-2 text-[11px] text-red-600" role="alert">数量必须是正整数</p> : null}
|
||||
</td>
|
||||
<td className="px-2 py-2 align-top"><input aria-label="工作记录单分值" type="number" min="0" step="0.01" value={formatScore(record.unitScore)} disabled={!canEdit || !isOther} onChange={(event) => {
|
||||
<td className="align-top"><WorkRecordColumnResizeFrame column="unitScore" {...columnResizeProps}><input aria-label="工作记录单分值" type="number" min="0" step="0.01" value={formatScore(record.unitScore)} disabled={!canEdit || !isOther} onChange={(event) => {
|
||||
const unitScore = event.target.value === "" ? null : Number(event.target.value);
|
||||
onUpdateRecord(record.id, {
|
||||
unitScore,
|
||||
@ -153,15 +252,17 @@ export function WorkRecordTable({
|
||||
});
|
||||
}} onBlur={() => {
|
||||
if (canCommitRecord(record)) onCommitRecord(record.id);
|
||||
}} className="h-8 w-full rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:cursor-default disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary" /></td>
|
||||
<td className="px-2 py-2 align-top"><input aria-label="工作记录单任务合计" readOnly value={formatScore(record.taskTotal)} className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-secondary outline-none" /></td>
|
||||
<td className="px-2 py-2 align-top">
|
||||
<div className="flex items-center gap-1">
|
||||
}} className="h-8 w-full rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:cursor-default disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary" /></WorkRecordColumnResizeFrame></td>
|
||||
<td className="align-top"><WorkRecordColumnResizeFrame column="taskTotal" {...columnResizeProps}><input aria-label="工作记录单任务合计" readOnly value={formatScore(record.taskTotal)} className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-secondary outline-none" /></WorkRecordColumnResizeFrame></td>
|
||||
<td className="align-top">
|
||||
<WorkRecordColumnResizeFrame column="fileStoragePath" {...columnResizeProps}>
|
||||
<input aria-label="文件储存路径" value={record.fileStoragePath} disabled={!canEdit} onChange={(event) => onUpdateRecord(record.id, { fileStoragePath: event.target.value })} onBlur={() => {
|
||||
if (canCommitRecord(record)) onCommitRecord(record.id);
|
||||
}} className="h-8 min-w-0 flex-1 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:cursor-default disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary" />
|
||||
{canEdit ? <button type="button" aria-label="删除工作记录" title="删除工作记录" onClick={() => onRemoveRecord(record.id)} className="inline-flex size-8 shrink-0 items-center justify-center rounded-[6px] text-apple-label-secondary transition hover:bg-red-50 hover:text-red-600"><Trash2 className="size-4" /></button> : null}
|
||||
</div>
|
||||
}} className="h-8 w-full rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:cursor-default disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary" />
|
||||
</WorkRecordColumnResizeFrame>
|
||||
</td>
|
||||
<td className="align-top">
|
||||
{canEdit ? <button type="button" aria-label="删除工作记录" title="删除工作记录" onClick={() => onRemoveRecord(record.id)} className="mt-2 inline-flex size-8 items-center justify-center rounded-[6px] text-apple-label-secondary transition hover:bg-red-50 hover:text-red-600"><Trash2 className="size-4" /></button> : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { WorkRecordWorkspace } from "@/app/intake/work-record/WorkRecordWorkspace";
|
||||
import {
|
||||
@ -209,4 +209,25 @@ describe("WorkRecordWorkspace", () => {
|
||||
expect(quantityInput).toHaveAttribute("step", "1");
|
||||
expect(await screen.findByText("数量必须是正整数")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the file storage path column compact and lets users widen it", async () => {
|
||||
const { container } = renderWorkspace();
|
||||
await screen.findByDisplayValue("SPU-001");
|
||||
|
||||
const columns = container.querySelectorAll("colgroup col");
|
||||
expect(columns).toHaveLength(11);
|
||||
expect(columns[9]).toHaveStyle({ width: "180px" });
|
||||
|
||||
const resizeHandles = screen.getAllByRole("separator");
|
||||
const pointerDown = new Event("pointerdown", { bubbles: true });
|
||||
Object.defineProperty(pointerDown, "clientX", { value: 100 });
|
||||
fireEvent(resizeHandles[9], pointerDown);
|
||||
|
||||
const pointerMove = new Event("pointermove");
|
||||
Object.defineProperty(pointerMove, "clientX", { value: 220 });
|
||||
fireEvent(window, pointerMove);
|
||||
fireEvent.pointerUp(window);
|
||||
|
||||
expect(columns[9]).toHaveStyle({ width: "300px" });
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user