feat(work-record): manage score rules
This commit is contained in:
parent
60f252571a
commit
679e726a14
@ -52,12 +52,20 @@ export async function PATCH(request: NextRequest) {
|
||||
ctx.requestId,
|
||||
);
|
||||
}
|
||||
const scoreRule =
|
||||
body && typeof body === "object" && "scoreRule" in body
|
||||
? (body as { scoreRule?: unknown }).scoreRule
|
||||
: body;
|
||||
return jsonSuccess(
|
||||
{
|
||||
scoreRule: await workRecordService.updateScoreRule(
|
||||
ctx.user,
|
||||
id,
|
||||
(body as { unitScore?: unknown }).unitScore,
|
||||
(scoreRule ?? {}) as {
|
||||
primary?: unknown;
|
||||
secondary?: unknown;
|
||||
unitScore?: unknown;
|
||||
},
|
||||
),
|
||||
},
|
||||
200,
|
||||
@ -65,3 +73,40 @@ export async function PATCH(request: NextRequest) {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const id = body && typeof body === "object"
|
||||
? String((body as { id?: unknown }).id ?? "").trim()
|
||||
: "";
|
||||
if (!id) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_REQUIRED_FIELD,
|
||||
"缺少分值规则标识",
|
||||
400,
|
||||
ctx.requestId,
|
||||
);
|
||||
}
|
||||
return jsonSuccess(
|
||||
await workRecordService.deleteScoreRule(ctx.user, id),
|
||||
200,
|
||||
ctx.requestId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const orderedIds =
|
||||
body && typeof body === "object"
|
||||
? (body as { orderedIds?: unknown }).orderedIds
|
||||
: undefined;
|
||||
return jsonSuccess(
|
||||
{ scoreRules: await workRecordService.reorderScoreRules(ctx.user, orderedIds) },
|
||||
200,
|
||||
ctx.requestId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import type { FormEvent } from "react";
|
||||
import { Check, X } from "lucide-react";
|
||||
import type { WorkRecordScoreRuleDraft } from "@/app/intake/work-record/workRecordScoreRuleTypes";
|
||||
|
||||
type WorkRecordScoreRuleCreateFormProps = {
|
||||
draft: WorkRecordScoreRuleDraft;
|
||||
isSaving: boolean;
|
||||
onChange: (draft: WorkRecordScoreRuleDraft) => void;
|
||||
onCancel: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
};
|
||||
|
||||
export function WorkRecordScoreRuleCreateForm({
|
||||
draft,
|
||||
isSaving,
|
||||
onChange,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: WorkRecordScoreRuleCreateFormProps) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="grid shrink-0 min-w-0 grid-cols-[minmax(0,1fr)_minmax(0,1fr)_7rem_auto] items-center gap-2 border-b border-[var(--obs-shell-border)] bg-apple-bg-secondary px-4 py-2"
|
||||
>
|
||||
<input
|
||||
aria-label="新增一级"
|
||||
required
|
||||
value={draft.primary}
|
||||
onChange={(event) => onChange({ ...draft, primary: event.target.value })}
|
||||
className="h-8 min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue"
|
||||
/>
|
||||
<input
|
||||
aria-label="新增二级"
|
||||
required
|
||||
value={draft.secondary}
|
||||
onChange={(event) => onChange({ ...draft, secondary: event.target.value })}
|
||||
className="h-8 min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue"
|
||||
/>
|
||||
<input
|
||||
aria-label="新增单分值"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={draft.unitScore}
|
||||
onChange={(event) => onChange({ ...draft, unitScore: event.target.value })}
|
||||
className="h-8 min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="submit"
|
||||
title="保存新增规则"
|
||||
aria-label="保存新增规则"
|
||||
disabled={isSaving}
|
||||
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-blue bg-apple-blue text-white transition hover:bg-apple-blue/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="取消新增规则"
|
||||
aria-label="取消新增规则"
|
||||
disabled={isSaving}
|
||||
onClick={onCancel}
|
||||
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-separator bg-white text-apple-label-secondary transition hover:border-apple-blue/40 hover:text-apple-label disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type CSSProperties, type KeyboardEvent } from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Check, GripVertical, Pencil, Trash2, X } from "lucide-react";
|
||||
import type { WorkRecordScoreRuleDraft } from "@/app/intake/work-record/workRecordScoreRuleTypes";
|
||||
import type { WorkRecordScoreRule } from "@/lib/work-record";
|
||||
|
||||
function toDraft(rule: WorkRecordScoreRule): WorkRecordScoreRuleDraft {
|
||||
return {
|
||||
primary: rule.primary,
|
||||
secondary: rule.secondary,
|
||||
unitScore: rule.unitScore === null ? "" : String(rule.unitScore),
|
||||
};
|
||||
}
|
||||
|
||||
type WorkRecordScoreRuleRowProps = {
|
||||
rule: WorkRecordScoreRule;
|
||||
canManageScoreRules: boolean;
|
||||
isSaving: boolean;
|
||||
onSave: (
|
||||
rule: WorkRecordScoreRule,
|
||||
draft: WorkRecordScoreRuleDraft,
|
||||
) => Promise<boolean>;
|
||||
onDelete: (rule: WorkRecordScoreRule) => void;
|
||||
};
|
||||
|
||||
export function WorkRecordScoreRuleRow({
|
||||
rule,
|
||||
canManageScoreRules,
|
||||
isSaving,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: WorkRecordScoreRuleRowProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<WorkRecordScoreRuleDraft>(() => toDraft(rule));
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: rule.id,
|
||||
disabled: !canManageScoreRules || isSaving,
|
||||
});
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.7 : 1,
|
||||
position: "relative",
|
||||
zIndex: isDragging ? 1 : undefined,
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setDraft(toDraft(rule));
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const saveEditing = async () => {
|
||||
if (!canManageScoreRules || isSaving) return;
|
||||
if (await onSave(rule, draft)) setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
void saveEditing();
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="border-b border-[var(--obs-shell-border)] bg-white last:border-b-0"
|
||||
>
|
||||
<td className="relative px-3 py-2 text-center text-apple-label">
|
||||
{canManageScoreRules ? (
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title="拖动排序"
|
||||
aria-label={`拖动规则 ${rule.primary}-${rule.secondary}`}
|
||||
disabled={isSaving}
|
||||
className="absolute left-2 top-1/2 z-10 inline-flex size-6 -translate-y-1/2 cursor-grab items-center justify-center rounded-[4px] text-apple-label-tertiary transition hover:bg-apple-bg-secondary hover:text-apple-label active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{isEditing ? (
|
||||
<input
|
||||
aria-label="编辑一级"
|
||||
value={draft.primary}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, primary: event.target.value }))
|
||||
}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
disabled={isSaving}
|
||||
className="ml-9 h-8 w-[calc(100%-2.25rem)] min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-center text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:opacity-60"
|
||||
/>
|
||||
) : (
|
||||
<span title={rule.primary} className="ml-9 block w-[calc(100%-2.25rem)] truncate whitespace-nowrap text-center">{rule.primary}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-apple-label">
|
||||
{isEditing ? (
|
||||
<input
|
||||
aria-label="编辑二级"
|
||||
value={draft.secondary}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, secondary: event.target.value }))
|
||||
}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
disabled={isSaving}
|
||||
className="h-8 w-full min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-center text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:opacity-60"
|
||||
/>
|
||||
) : (
|
||||
<span title={rule.secondary} className="block truncate whitespace-nowrap text-center">{rule.secondary}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
aria-label="编辑单分值"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={draft.unitScore}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, unitScore: event.target.value }))
|
||||
}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
disabled={isSaving}
|
||||
className="h-8 w-full min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-center text-xs text-apple-label outline-none transition focus:border-apple-blue disabled:opacity-60"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
aria-label={`${rule.primary}-${rule.secondary}-单分值`}
|
||||
type="number"
|
||||
value={rule.unitScore ?? ""}
|
||||
readOnly
|
||||
className="h-8 w-full rounded-[6px] border border-apple-separator bg-apple-bg-secondary px-2 text-center text-xs text-apple-label-secondary outline-none"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
{canManageScoreRules ? (
|
||||
<td className="sticky right-0 z-10 w-28 bg-white px-3 py-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title="保存修改"
|
||||
aria-label={`保存规则 ${rule.primary}-${rule.secondary}`}
|
||||
disabled={isSaving}
|
||||
onClick={() => void saveEditing()}
|
||||
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-blue bg-apple-blue text-white transition hover:bg-apple-blue/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="取消修改"
|
||||
aria-label={`取消修改 ${rule.primary}-${rule.secondary}`}
|
||||
disabled={isSaving}
|
||||
onClick={cancelEditing}
|
||||
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-separator bg-white text-apple-label-secondary transition hover:border-apple-blue/40 hover:text-apple-label disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title="修改规则"
|
||||
aria-label={`编辑规则 ${rule.primary}-${rule.secondary}`}
|
||||
disabled={isSaving}
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-separator bg-white text-apple-label-secondary transition hover:border-apple-blue/40 hover:text-apple-label disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="删除规则"
|
||||
aria-label={`删除规则 ${rule.primary}-${rule.secondary}`}
|
||||
disabled={isSaving}
|
||||
onClick={() => onDelete(rule)}
|
||||
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-red-200 bg-white text-red-600 transition hover:border-red-300 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@ -3,20 +3,26 @@ import userEvent from "@testing-library/user-event";
|
||||
import { WorkRecordScoreWorkspace } from "@/app/intake/work-record/WorkRecordScoreWorkspace";
|
||||
import {
|
||||
createWorkRecordScoreRule,
|
||||
deleteWorkRecordScoreRule,
|
||||
fetchWorkRecordScoreRules,
|
||||
reorderWorkRecordScoreRules,
|
||||
updateWorkRecordScoreRule,
|
||||
} from "@/app/intake/work-record/workRecordApi";
|
||||
import type { WorkRecordScoreRule } from "@/lib/work-record";
|
||||
|
||||
jest.mock("@/app/intake/work-record/workRecordApi", () => ({
|
||||
createWorkRecordScoreRule: jest.fn(),
|
||||
deleteWorkRecordScoreRule: jest.fn(),
|
||||
fetchWorkRecordScoreRules: jest.fn(),
|
||||
reorderWorkRecordScoreRules: jest.fn(),
|
||||
updateWorkRecordScoreRule: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockCreateWorkRecordScoreRule = jest.mocked(createWorkRecordScoreRule);
|
||||
const mockFetchWorkRecordScoreRules = jest.mocked(fetchWorkRecordScoreRules);
|
||||
const mockUpdateWorkRecordScoreRule = jest.mocked(updateWorkRecordScoreRule);
|
||||
const mockDeleteWorkRecordScoreRule = jest.mocked(deleteWorkRecordScoreRule);
|
||||
const mockReorderWorkRecordScoreRules = jest.mocked(reorderWorkRecordScoreRules);
|
||||
|
||||
const scoreRule: WorkRecordScoreRule = {
|
||||
id: "score-1",
|
||||
@ -32,6 +38,8 @@ describe("WorkRecordScoreWorkspace", () => {
|
||||
mockFetchWorkRecordScoreRules.mockResolvedValue({ scoreRules: [scoreRule] });
|
||||
mockCreateWorkRecordScoreRule.mockResolvedValue({ scoreRule });
|
||||
mockUpdateWorkRecordScoreRule.mockResolvedValue({ scoreRule });
|
||||
mockDeleteWorkRecordScoreRule.mockResolvedValue({ id: scoreRule.id });
|
||||
mockReorderWorkRecordScoreRules.mockResolvedValue({ scoreRules: [scoreRule] });
|
||||
});
|
||||
|
||||
it("keeps score inputs read-only for a designer", async () => {
|
||||
@ -40,20 +48,63 @@ describe("WorkRecordScoreWorkspace", () => {
|
||||
expect(await screen.findByRole("spinbutton")).toHaveAttribute("readonly");
|
||||
});
|
||||
|
||||
it("persists a supervisor score change on blur", async () => {
|
||||
it("lets a supervisor edit the primary, secondary, and unit score", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUpdateWorkRecordScoreRule.mockResolvedValueOnce({
|
||||
scoreRule: { ...scoreRule, unitScore: 16 },
|
||||
scoreRule: {
|
||||
...scoreRule,
|
||||
primary: "Updated primary",
|
||||
secondary: "Updated secondary",
|
||||
unitScore: 16,
|
||||
},
|
||||
});
|
||||
render(<WorkRecordScoreWorkspace canManageScoreRules />);
|
||||
|
||||
const input = await screen.findByRole("spinbutton");
|
||||
await user.clear(input);
|
||||
await user.type(input, "16");
|
||||
await user.tab();
|
||||
await screen.findByRole("spinbutton");
|
||||
await user.click(screen.getByRole("button", { name: "编辑规则 Primary-Secondary" }));
|
||||
await user.clear(screen.getByLabelText("编辑一级"));
|
||||
await user.type(screen.getByLabelText("编辑一级"), "Updated primary");
|
||||
await user.clear(screen.getByLabelText("编辑二级"));
|
||||
await user.type(screen.getByLabelText("编辑二级"), "Updated secondary");
|
||||
await user.clear(screen.getByLabelText("编辑单分值"));
|
||||
await user.type(screen.getByLabelText("编辑单分值"), "16");
|
||||
await user.click(screen.getByRole("button", { name: "保存规则 Primary-Secondary" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateWorkRecordScoreRule).toHaveBeenCalledWith(scoreRule.id, 16),
|
||||
expect(mockUpdateWorkRecordScoreRule).toHaveBeenCalledWith(scoreRule.id, {
|
||||
primary: "Updated primary",
|
||||
secondary: "Updated secondary",
|
||||
unitScore: 16,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows drag, edit, and delete controls only to a supervisor", async () => {
|
||||
render(<WorkRecordScoreWorkspace canManageScoreRules />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "拖动规则 Primary-Secondary" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "删除规则 Primary-Secondary" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "一级" })).toHaveClass("sticky");
|
||||
expect(screen.getByRole("columnheader", { name: "操作" })).toHaveClass("sticky", "right-0");
|
||||
expect(screen.getByText(scoreRule.primary)).toHaveClass("truncate", "whitespace-nowrap");
|
||||
expect(screen.getByText(scoreRule.secondary)).toHaveClass("truncate", "whitespace-nowrap");
|
||||
});
|
||||
|
||||
it("deactivates a selected score rule after confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
jest.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(<WorkRecordScoreWorkspace canManageScoreRules />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "删除规则 Primary-Secondary" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockDeleteWorkRecordScoreRule).toHaveBeenCalledWith(scoreRule.id),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -1,31 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Check, CloudUpload, Plus, TriangleAlert, X } from "lucide-react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { Check, CloudUpload, Plus, TriangleAlert } from "lucide-react";
|
||||
import { BrandFilingWorkbenchSurface } from "@/app/intake/brand-filing/BrandFilingWorkbenchSurface";
|
||||
import {
|
||||
createWorkRecordScoreRule,
|
||||
deleteWorkRecordScoreRule,
|
||||
fetchWorkRecordScoreRules,
|
||||
reorderWorkRecordScoreRules,
|
||||
updateWorkRecordScoreRule,
|
||||
} from "@/app/intake/work-record/workRecordApi";
|
||||
import {
|
||||
WorkRecordScoreRuleRow,
|
||||
} from "@/app/intake/work-record/WorkRecordScoreRuleRow";
|
||||
import { WorkRecordScoreRuleCreateForm } from "@/app/intake/work-record/WorkRecordScoreRuleCreateForm";
|
||||
import type { WorkRecordScoreRuleDraft } from "@/app/intake/work-record/workRecordScoreRuleTypes";
|
||||
import type { WorkRecordScoreRule } from "@/lib/work-record";
|
||||
|
||||
type ScoreRuleDraft = {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
unitScore: string;
|
||||
};
|
||||
|
||||
const EMPTY_SCORE_RULE_DRAFT: ScoreRuleDraft = {
|
||||
const EMPTY_SCORE_RULE_DRAFT: WorkRecordScoreRuleDraft = {
|
||||
primary: "",
|
||||
secondary: "",
|
||||
unitScore: "",
|
||||
};
|
||||
|
||||
function formatScore(value: number | null) {
|
||||
return value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
export function WorkRecordScoreWorkspace({
|
||||
canManageScoreRules,
|
||||
}: {
|
||||
@ -35,10 +41,13 @@ export function WorkRecordScoreWorkspace({
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newRule, setNewRule] = useState<ScoreRuleDraft>(
|
||||
const [newRule, setNewRule] = useState<WorkRecordScoreRuleDraft>(
|
||||
EMPTY_SCORE_RULE_DRAFT,
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@ -47,7 +56,11 @@ export function WorkRecordScoreWorkspace({
|
||||
if (active) setScoreRules(result.scoreRules);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) setErrorMessage(error instanceof Error ? error.message : "加载产品工作分数失败");
|
||||
if (active) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "加载产品工作分数失败",
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setIsLoading(false);
|
||||
@ -57,15 +70,29 @@ export function WorkRecordScoreWorkspace({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveRule = async (rule: WorkRecordScoreRule) => {
|
||||
if (!canManageScoreRules) return;
|
||||
const saveEditedRule = async (
|
||||
rule: WorkRecordScoreRule,
|
||||
draft: WorkRecordScoreRuleDraft,
|
||||
) => {
|
||||
if (!canManageScoreRules || isSaving) return false;
|
||||
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
try {
|
||||
const result = await updateWorkRecordScoreRule(rule.id, rule.unitScore);
|
||||
setScoreRules((current) => current.map((item) => item.id === rule.id ? result.scoreRule : item));
|
||||
const result = await updateWorkRecordScoreRule(rule.id, {
|
||||
primary: draft.primary.trim(),
|
||||
secondary: draft.secondary.trim(),
|
||||
unitScore: draft.unitScore === "" ? null : Number(draft.unitScore),
|
||||
});
|
||||
setScoreRules((current) =>
|
||||
current.map((item) => (item.id === rule.id ? result.scoreRule : item)),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : "保存产品工作分数失败");
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "保存产品工作分数失败",
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@ -87,16 +114,68 @@ export function WorkRecordScoreWorkspace({
|
||||
setNewRule(EMPTY_SCORE_RULE_DRAFT);
|
||||
setIsCreating(false);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : "新增产品工作分数失败");
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "新增产品工作分数失败",
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRule = async (rule: WorkRecordScoreRule) => {
|
||||
if (
|
||||
!canManageScoreRules ||
|
||||
isSaving ||
|
||||
!window.confirm(`删除“${rule.primary} / ${rule.secondary}”规则?已有工作记录不会删除。`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
try {
|
||||
await deleteWorkRecordScoreRule(rule.id);
|
||||
setScoreRules((current) => current.filter((item) => item.id !== rule.id));
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "删除产品工作分数失败",
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
if (!canManageScoreRules || isSaving) return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const previousRules = scoreRules;
|
||||
const oldIndex = previousRules.findIndex((rule) => rule.id === active.id);
|
||||
const newIndex = previousRules.findIndex((rule) => rule.id === over.id);
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
|
||||
const reorderedRules = arrayMove(previousRules, oldIndex, newIndex).map(
|
||||
(rule, index) => ({ ...rule, sortOrder: index + 1 }),
|
||||
);
|
||||
setScoreRules(reorderedRules);
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
void reorderWorkRecordScoreRules(reorderedRules.map((rule) => rule.id))
|
||||
.then((result) => setScoreRules(result.scoreRules))
|
||||
.catch((error) => {
|
||||
setScoreRules(previousRules);
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "保存分值规则排序失败",
|
||||
);
|
||||
})
|
||||
.finally(() => setIsSaving(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<BrandFilingWorkbenchSurface>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center justify-between border-b border-[var(--obs-shell-border)] px-4 py-3">
|
||||
<div className="flex h-[calc(100dvh-11rem)] min-h-[420px] min-w-0 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[var(--obs-shell-border)] px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-[var(--obs-shell-title)]">产品工作分数</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{canManageScoreRules ? (
|
||||
@ -115,96 +194,88 @@ export function WorkRecordScoreWorkspace({
|
||||
新增规则
|
||||
</button>
|
||||
) : null}
|
||||
{isSaving ? <span title="保存中"><CloudUpload className="size-4 animate-pulse text-apple-blue" /></span> : <span title="已保存"><Check className="size-4 text-emerald-600" /></span>}
|
||||
{isSaving ? (
|
||||
<span title="保存中">
|
||||
<CloudUpload className="size-4 animate-pulse text-apple-blue" />
|
||||
</span>
|
||||
) : (
|
||||
<span title="已保存">
|
||||
<Check className="size-4 text-emerald-600" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage ? <div className="mx-4 mt-3 flex items-center gap-2 rounded-[6px] border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700" role="alert"><TriangleAlert className="size-4 shrink-0" />{errorMessage}</div> : null}
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{isLoading ? <div className="flex h-24 items-center justify-center text-xs text-apple-label-secondary">加载中</div> : (
|
||||
<div className="overflow-x-auto rounded-[8px] border border-[var(--obs-shell-border)] bg-white">
|
||||
{isCreating && canManageScoreRules ? (
|
||||
<form
|
||||
onSubmit={createRule}
|
||||
className="grid min-w-[720px] grid-cols-[minmax(0,1fr)_minmax(0,1fr)_7rem_auto] items-center gap-2 border-b border-[var(--obs-shell-border)] bg-apple-bg-secondary px-3 py-2"
|
||||
>
|
||||
<input
|
||||
aria-label="新增一级"
|
||||
required
|
||||
value={newRule.primary}
|
||||
onChange={(event) => setNewRule((current) => ({
|
||||
...current,
|
||||
primary: event.target.value,
|
||||
}))}
|
||||
className="h-8 min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue"
|
||||
/>
|
||||
<input
|
||||
aria-label="新增二级"
|
||||
required
|
||||
value={newRule.secondary}
|
||||
onChange={(event) => setNewRule((current) => ({
|
||||
...current,
|
||||
secondary: event.target.value,
|
||||
}))}
|
||||
className="h-8 min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue"
|
||||
/>
|
||||
<input
|
||||
aria-label="新增单分值"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={newRule.unitScore}
|
||||
onChange={(event) => setNewRule((current) => ({
|
||||
...current,
|
||||
unitScore: event.target.value,
|
||||
}))}
|
||||
className="h-8 min-w-0 rounded-[6px] border border-apple-separator bg-white px-2 text-xs text-apple-label outline-none transition focus:border-apple-blue"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="submit"
|
||||
title="保存新增规则"
|
||||
aria-label="保存新增规则"
|
||||
disabled={isSaving}
|
||||
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-blue bg-apple-blue text-white transition hover:bg-apple-blue/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="取消新增规则"
|
||||
aria-label="取消新增规则"
|
||||
disabled={isSaving}
|
||||
onClick={() => {
|
||||
setNewRule(EMPTY_SCORE_RULE_DRAFT);
|
||||
setIsCreating(false);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
className="inline-flex size-8 items-center justify-center rounded-[6px] border border-apple-separator bg-white text-apple-label-secondary transition hover:border-apple-blue/40 hover:text-apple-label disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
<table className="w-full min-w-[720px] border-collapse text-xs">
|
||||
<thead className="bg-[var(--obs-shell-panel-bg)] text-[var(--obs-shell-title)]"><tr><th className="w-56 border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-medium">一级</th><th className="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></tr></thead>
|
||||
<tbody>
|
||||
{scoreRules.map((rule) => (
|
||||
<tr key={rule.id} className="border-b border-[var(--obs-shell-border)] last:border-b-0">
|
||||
<td className="px-3 py-2 text-apple-label">{rule.primary}</td>
|
||||
<td className="px-3 py-2 text-apple-label">{rule.secondary}</td>
|
||||
<td className="px-3 py-2"><input aria-label={`${rule.primary}-${rule.secondary}-单分值`} type="number" min="0" step="0.01" value={formatScore(rule.unitScore)} readOnly={!canManageScoreRules} onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setScoreRules((current) => current.map((item) => item.id === rule.id ? { ...item, unitScore: nextValue === "" ? null : Number(nextValue) } : item));
|
||||
}} onBlur={(event) => {
|
||||
const unitScore = event.target.value === "" ? null : Number(event.target.value);
|
||||
void saveRule({ ...rule, unitScore });
|
||||
}} 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 read-only:cursor-default read-only:bg-apple-bg-secondary read-only:text-apple-label-secondary" /></td>
|
||||
|
||||
{isCreating && canManageScoreRules ? (
|
||||
<WorkRecordScoreRuleCreateForm
|
||||
draft={newRule}
|
||||
isSaving={isSaving}
|
||||
onChange={setNewRule}
|
||||
onSubmit={createRule}
|
||||
onCancel={() => {
|
||||
setNewRule(EMPTY_SCORE_RULE_DRAFT);
|
||||
setIsCreating(false);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="mx-4 mt-3 flex shrink-0 items-center gap-2 rounded-[6px] border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700" role="alert">
|
||||
<TriangleAlert className="size-4 shrink-0" />
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-4 pb-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-24 items-center justify-center text-xs text-apple-label-secondary">加载中</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={canManageScoreRules ? sensors : undefined}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex h-full min-h-0 w-full flex-col rounded-[8px] border border-[var(--obs-shell-border)] bg-white">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="w-full min-w-[900px] table-fixed border-collapse text-xs">
|
||||
<colgroup>
|
||||
<col className="w-72" />
|
||||
<col />
|
||||
<col className="w-28" />
|
||||
{canManageScoreRules ? <col className="w-28" /> : null}
|
||||
</colgroup>
|
||||
<thead className="text-[var(--obs-shell-title)]">
|
||||
<tr>
|
||||
<th className="sticky top-0 z-20 w-72 whitespace-nowrap border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] py-2 pl-12 pr-3 text-center font-medium">一级</th>
|
||||
<th className="sticky top-0 z-20 whitespace-nowrap border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-3 py-2 text-center font-medium">二级</th>
|
||||
<th className="sticky top-0 z-20 w-28 whitespace-nowrap border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-3 py-2 text-center font-medium">单分值</th>
|
||||
{canManageScoreRules ? (
|
||||
<th className="sticky right-0 top-0 z-30 w-28 whitespace-nowrap border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-3 py-2 text-center font-medium">操作</th>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<SortableContext
|
||||
items={scoreRules.map((rule) => rule.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody>
|
||||
{scoreRules.map((rule) => (
|
||||
<WorkRecordScoreRuleRow
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
canManageScoreRules={canManageScoreRules}
|
||||
isSaving={isSaving}
|
||||
onSave={saveEditedRule}
|
||||
onDelete={(target) => void deleteRule(target)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -89,15 +89,40 @@ export async function createWorkRecordScoreRule(rule: {
|
||||
return readApiData<{ scoreRule: WorkRecordScoreRule }>(response);
|
||||
}
|
||||
|
||||
export async function updateWorkRecordScoreRule(id: string, unitScore: number | null) {
|
||||
export async function updateWorkRecordScoreRule(
|
||||
id: string,
|
||||
scoreRule: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
unitScore: number | null;
|
||||
},
|
||||
) {
|
||||
const response = await fetch("/api/intake/work-record/scores", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, unitScore }),
|
||||
body: JSON.stringify({ id, scoreRule }),
|
||||
});
|
||||
return readApiData<{ scoreRule: WorkRecordScoreRule }>(response);
|
||||
}
|
||||
|
||||
export async function deleteWorkRecordScoreRule(id: string) {
|
||||
const response = await fetch("/api/intake/work-record/scores", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
return readApiData<{ id: string }>(response);
|
||||
}
|
||||
|
||||
export async function reorderWorkRecordScoreRules(orderedIds: string[]) {
|
||||
const response = await fetch("/api/intake/work-record/scores", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderedIds }),
|
||||
});
|
||||
return readApiData<{ scoreRules: WorkRecordScoreRule[] }>(response);
|
||||
}
|
||||
|
||||
export async function fetchCurrentWorkRecordScoreSummary() {
|
||||
const response = await fetch("/api/intake/work-record/summary", { cache: "no-store" });
|
||||
return readApiData<{ summary: WorkRecordScoreSummary[] }>(response);
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
export type WorkRecordScoreRuleDraft = {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
unitScore: string;
|
||||
};
|
||||
@ -109,13 +109,34 @@ class WorkRecordRepository {
|
||||
return prisma.workRecordScoreRule.create({ data });
|
||||
}
|
||||
|
||||
async updateScoreRule(id: string, unitScore: Prisma.Decimal | null) {
|
||||
async updateScoreRule(
|
||||
id: string,
|
||||
data: Prisma.WorkRecordScoreRuleUpdateInput,
|
||||
) {
|
||||
return prisma.workRecordScoreRule.update({
|
||||
where: { id },
|
||||
data: { unitScore },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async deactivateScoreRule(id: string) {
|
||||
return prisma.workRecordScoreRule.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
|
||||
async reorderScoreRules(ids: string[]) {
|
||||
await prisma.$transaction(
|
||||
ids.map((id, index) =>
|
||||
prisma.workRecordScoreRule.update({
|
||||
where: { id },
|
||||
data: { sortOrder: index + 1 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async summarizeByDesigner(periodStart: Date, periodEnd: Date) {
|
||||
return prisma.workRecord.groupBy({
|
||||
by: ["designerId", "designerName"],
|
||||
|
||||
@ -5,10 +5,13 @@ jest.mock("@/server/repositories/work-record.repository", () => ({
|
||||
createScoreRule: jest.fn(),
|
||||
createManyRecords: jest.fn(),
|
||||
createRecord: jest.fn(),
|
||||
deactivateScoreRule: jest.fn(),
|
||||
findActiveScoreRuleByLabels: jest.fn(),
|
||||
findScoreRuleById: jest.fn(),
|
||||
findScoreRules: jest.fn(),
|
||||
getNextScoreRuleSortOrder: jest.fn(),
|
||||
reorderScoreRules: jest.fn(),
|
||||
updateScoreRule: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@ -32,6 +35,9 @@ const mockCreateManyRecords = jest.mocked(workRecordRepository.createManyRecords
|
||||
const mockCreateRecord = jest.mocked(workRecordRepository.createRecord);
|
||||
const mockFindScoreRuleById = jest.mocked(workRecordRepository.findScoreRuleById);
|
||||
const mockFindScoreRules = jest.mocked(workRecordRepository.findScoreRules);
|
||||
const mockUpdateScoreRule = jest.mocked(workRecordRepository.updateScoreRule);
|
||||
const mockDeactivateScoreRule = jest.mocked(workRecordRepository.deactivateScoreRule);
|
||||
const mockReorderScoreRules = jest.mocked(workRecordRepository.reorderScoreRules);
|
||||
|
||||
const supervisor = {
|
||||
id: "supervisor-1",
|
||||
@ -108,6 +114,131 @@ describe("resolveCurrentMonthQuery", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a supervisor edit a rule name and score after checking the active key", async () => {
|
||||
const existing = {
|
||||
id: "score-edit",
|
||||
primary: "旧一级",
|
||||
secondary: "旧二级",
|
||||
unitScore: new Prisma.Decimal("3"),
|
||||
sortOrder: 5,
|
||||
};
|
||||
const updated = {
|
||||
...existing,
|
||||
primary: "新一级",
|
||||
secondary: "新二级",
|
||||
unitScore: new Prisma.Decimal("6.5"),
|
||||
};
|
||||
mockFindScoreRuleById.mockResolvedValue(existing as never);
|
||||
mockFindActiveScoreRuleByLabels.mockResolvedValue(null);
|
||||
mockUpdateScoreRule.mockResolvedValue(updated as never);
|
||||
|
||||
await expect(
|
||||
workRecordService.updateScoreRule(supervisor, existing.id, {
|
||||
primary: updated.primary,
|
||||
secondary: updated.secondary,
|
||||
unitScore: 6.5,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
id: updated.id,
|
||||
primary: updated.primary,
|
||||
secondary: updated.secondary,
|
||||
unitScore: 6.5,
|
||||
sortOrder: updated.sortOrder,
|
||||
});
|
||||
|
||||
expect(mockUpdateScoreRule).toHaveBeenCalledWith(
|
||||
existing.id,
|
||||
expect.objectContaining({
|
||||
primary: updated.primary,
|
||||
secondary: updated.secondary,
|
||||
unitScore: new Prisma.Decimal("6.5"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an edited rule that duplicates another active primary and secondary", async () => {
|
||||
const existing = {
|
||||
id: "score-edit",
|
||||
primary: "旧一级",
|
||||
secondary: "旧二级",
|
||||
unitScore: new Prisma.Decimal("3"),
|
||||
sortOrder: 5,
|
||||
};
|
||||
mockFindScoreRuleById.mockResolvedValue(existing as never);
|
||||
mockFindActiveScoreRuleByLabels.mockResolvedValue({
|
||||
...existing,
|
||||
id: "score-duplicate",
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
workRecordService.updateScoreRule(supervisor, existing.id, {
|
||||
primary: "重复一级",
|
||||
secondary: "重复二级",
|
||||
}),
|
||||
).rejects.toMatchObject({ message: "该一级、二级分值规则已存在" });
|
||||
|
||||
expect(mockUpdateScoreRule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deactivates a score rule without deleting historical work records", async () => {
|
||||
mockFindScoreRuleById.mockResolvedValue({
|
||||
id: "score-remove",
|
||||
primary: "不再使用",
|
||||
secondary: "规则",
|
||||
unitScore: new Prisma.Decimal("1"),
|
||||
sortOrder: 6,
|
||||
} as never);
|
||||
mockDeactivateScoreRule.mockResolvedValue({ id: "score-remove" } as never);
|
||||
|
||||
await expect(
|
||||
workRecordService.deleteScoreRule(supervisor, "score-remove"),
|
||||
).resolves.toEqual({ id: "score-remove" });
|
||||
|
||||
expect(mockDeactivateScoreRule).toHaveBeenCalledWith("score-remove");
|
||||
});
|
||||
|
||||
it("persists a supervisor's complete score-rule order", async () => {
|
||||
const rules = [
|
||||
{
|
||||
id: "score-2",
|
||||
primary: "第二项",
|
||||
secondary: "二级",
|
||||
unitScore: new Prisma.Decimal("2"),
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
id: "score-1",
|
||||
primary: "第一项",
|
||||
secondary: "二级",
|
||||
unitScore: new Prisma.Decimal("1"),
|
||||
sortOrder: 2,
|
||||
},
|
||||
];
|
||||
mockFindScoreRules.mockResolvedValue(rules as never);
|
||||
mockReorderScoreRules.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
workRecordService.reorderScoreRules(supervisor, ["score-2", "score-1"]),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
id: "score-2",
|
||||
primary: "第二项",
|
||||
secondary: "二级",
|
||||
unitScore: 2,
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
id: "score-1",
|
||||
primary: "第一项",
|
||||
secondary: "二级",
|
||||
unitScore: 1,
|
||||
sortOrder: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mockReorderScoreRules).toHaveBeenCalledWith(["score-2", "score-1"]);
|
||||
});
|
||||
|
||||
it("rejects a sample-order record without an SPU", async () => {
|
||||
mockFindScoreRuleById.mockResolvedValue({
|
||||
id: "sample-rule",
|
||||
|
||||
@ -53,6 +53,8 @@ export type WorkRecordScoreRuleCreateInput = {
|
||||
unitScore?: unknown;
|
||||
};
|
||||
|
||||
export type WorkRecordScoreRuleUpdateInput = WorkRecordScoreRuleCreateInput;
|
||||
|
||||
type WorkRecordActor = IntakeNonIntegratedPermissionUser & {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -591,17 +593,103 @@ class WorkRecordService {
|
||||
return (await workRecordRepository.findScoreRules()).map(toScoreRuleDto);
|
||||
}
|
||||
|
||||
async updateScoreRule(user: WorkRecordActor, id: string, unitScore: unknown) {
|
||||
async updateScoreRule(
|
||||
user: WorkRecordActor,
|
||||
id: string,
|
||||
input: WorkRecordScoreRuleUpdateInput,
|
||||
) {
|
||||
assertCanManageScores(user);
|
||||
await this.ensureScoreRules();
|
||||
const normalized = normalizeUnitScore(unitScore);
|
||||
const updated = await workRecordRepository.updateScoreRule(
|
||||
id,
|
||||
normalized === null ? null : new Prisma.Decimal(normalized),
|
||||
const existing = await workRecordRepository.findScoreRuleById(id);
|
||||
if (!existing) {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, "产品工作分数规则不存在");
|
||||
}
|
||||
|
||||
const primary =
|
||||
input.primary === undefined ? existing.primary : normalizeText(input.primary);
|
||||
const secondary =
|
||||
input.secondary === undefined
|
||||
? existing.secondary
|
||||
: normalizeText(input.secondary);
|
||||
if (!primary || !secondary) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_REQUIRED_FIELD,
|
||||
"一级和二级不能为空",
|
||||
);
|
||||
}
|
||||
|
||||
const duplicate = await workRecordRepository.findActiveScoreRuleByLabels(
|
||||
primary,
|
||||
secondary,
|
||||
);
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
"该一级、二级分值规则已存在",
|
||||
);
|
||||
}
|
||||
|
||||
const unitScore = normalizeUnitScore(
|
||||
input.unitScore === undefined
|
||||
? existing.unitScore?.toString() ?? null
|
||||
: input.unitScore,
|
||||
);
|
||||
const updated = await workRecordRepository.updateScoreRule(id, {
|
||||
primary,
|
||||
secondary,
|
||||
unitScore: unitScore === null ? null : new Prisma.Decimal(unitScore),
|
||||
});
|
||||
return toScoreRuleDto(updated);
|
||||
}
|
||||
|
||||
async deleteScoreRule(user: WorkRecordActor, id: string) {
|
||||
assertCanManageScores(user);
|
||||
await this.ensureScoreRules();
|
||||
const existing = await workRecordRepository.findScoreRuleById(id);
|
||||
if (!existing) {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, "产品工作分数规则不存在");
|
||||
}
|
||||
await workRecordRepository.deactivateScoreRule(id);
|
||||
return { id };
|
||||
}
|
||||
|
||||
async reorderScoreRules(user: WorkRecordActor, ids: unknown) {
|
||||
assertCanManageScores(user);
|
||||
await this.ensureScoreRules();
|
||||
if (!Array.isArray(ids)) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
"分值规则排序格式不正确",
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedIds = ids.map((id) => normalizeText(id));
|
||||
if (
|
||||
normalizedIds.some((id) => !id) ||
|
||||
new Set(normalizedIds).size !== normalizedIds.length
|
||||
) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
"分值规则排序格式不正确",
|
||||
);
|
||||
}
|
||||
|
||||
const activeRules = await workRecordRepository.findScoreRules();
|
||||
const activeIds = new Set(activeRules.map((rule) => rule.id));
|
||||
if (
|
||||
normalizedIds.length !== activeRules.length ||
|
||||
normalizedIds.some((id) => !activeIds.has(id))
|
||||
) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
"分值规则已变化,请刷新后重新排序",
|
||||
);
|
||||
}
|
||||
|
||||
await workRecordRepository.reorderScoreRules(normalizedIds);
|
||||
return (await workRecordRepository.findScoreRules()).map(toScoreRuleDto);
|
||||
}
|
||||
|
||||
async createScoreRule(
|
||||
user: WorkRecordActor,
|
||||
input: WorkRecordScoreRuleCreateInput,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user