Files
ERP/frontend/src/pages/Wizard/Step4Projects.tsx
T
whqreza 43ff048899 fix(wizard,quotes,dates): reliable error routing, single-project accept, Persian date/time input
Wizard:
- failed saves now route to the exact step+field: a new data-field attribute on every required section (supplier_id, line_items, termsheets, project, quote_date, expiry_date, notes, proforma) plus resolveErrorTarget() that maps ApiError.field / message keywords (supplier, line item/material/unit/quantity, termsheet/schedule/milestone, project, dates, notes, file) to the offending step — the «رفع خطا» toast action scrolls/focuses the right control instead of guessing
- material records are no longer created on the fly while typing (rememberMaterial no longer fires addMaterial per blur); materials/specs/units are written once during the final save, avoiding half-created catalog rows

Accept flow:
- quote_service no longer rewrites LineItemProjects on accept — those candidate-project rows stay 100% untouched and only item_decisions.project_id is written, matching the per-item target-project model and the UI payload (item_projects: [])

Dates/time:
- JalaliDatePicker: hour/minute are now editable text fields (Persian digits, clamping, 3-digit retype handling) with a controlled useEffect that only rewrites the inputs when the numeric value actually changes — no more wiping in-progress typing; date-only detection tightened to a full YYYY-MM-DD match so datetime strings are not misread

Also: /api validation logging (RequestValidationError) now includes the path and error list for faster diagnosis.

Verified: api + frontend image builds (python, tsc, vite) clean on top of origin/production (incl. the team's mobile layout merges).
2026-09-17 16:13:27 +03:30

245 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { DndContext, PointerSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import { faNum } from "../../lib/formatters";
import { normalize } from "../../lib/normalize";
import { itemRowLabel, type ItemDraft } from "./Step2LineItems";
import { cn } from "../../lib/utils";
export interface Step4Props {
items: ItemDraft[];
projects: string[];
assignment: Record<string, string[]>; // tempId → project names
onAssign: (tempId: string, proj: string) => void;
onUnassign: (tempId: string, proj: string) => void;
onAddProject: (name: string) => void;
}
const ALL_KEY = "__all__";
function DraggableItem({
item,
label,
assignedCount,
assignedNames,
picked,
isAll,
onPick,
}: {
item?: ItemDraft;
label: string;
assignedCount: number;
assignedNames: string[];
picked: boolean;
isAll: boolean;
onPick: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: isAll ? "item-" + ALL_KEY : "item-" + item!.tempId,
data: { tempId: isAll ? ALL_KEY : item!.tempId, isAll },
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
onClick={onPick}
className={cn(
"dd-item",
!isAll && assignedCount === 0 && "dd-unassigned",
picked && "picked",
isDragging && "dragging",
isAll && "border-accent/40 bg-accent-bg/30",
)}
style={{ transform: CSS.Translate.toString(transform) }}
>
<div className="min-w-0">
<span className="dd-label">{label}</span>
{assignedNames.length > 0 && (
<div className="mt-0.5 text-[10px] text-muted">لینک‌شده به: {assignedNames.join("، ")}</div>
)}
</div>
<span className="dd-count">{isAll ? "" : assignedCount ? faNum(assignedCount) + " پروژه" : "بدون پروژه"}</span>
</div>
);
}
function DroppableProject({
name,
tags,
onTagRemove,
onPickProject,
}: {
name: string;
tags: { tempId: string; label: string }[];
onTagRemove: (tempId: string) => void;
onPickProject: () => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: "proj-" + name, data: { proj: name } });
return (
<div
ref={setNodeRef}
onClick={onPickProject}
className={cn("dd-project", isOver && "drop-hover")}
>
<div className="dp-name">🏗️ {name}</div>
<div className="dp-tags">
{tags.length ? (
tags.map((t) => (
<span key={t.tempId} className="dd-tag">
{t.label}{" "}
<button
type="button"
className="dd-tag-x"
title="حذف از این پروژه"
onClick={(e) => {
e.stopPropagation();
onTagRemove(t.tempId);
}}
>
×
</button>
</span>
))
) : (
<div className="dd-proj-empty">قلمی برای این پروژه رها نشده</div>
)}
</div>
</div>
);
}
// مرحله ۴ — تخصیص پروژه‌ها با درگ‌اندروپ (یا کلیک-قلم بعد کلیک-پروژه)
export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject }: Step4Props) {
const [picked, setPicked] = useState<string | null>(null);
const [newProj, setNewProj] = useState("");
// فاصله‌ی حداقلی قبل از شروع درگ — تا کلیک ساده روی قلم، «انتخاب قلم» باشد
// نه شروع درگ (بدون این، dnd-kit رویداد click را می‌بلعد و کلیک-انتخاب کار نمی‌کند)
// TouchSensor با تاخیر فعال‌سازی: درگ لمسی روی موبایل کار می‌کند بدون اینکه
// اسکرول صفحه را بدزدد (کلیک-قلم بعد کلیک-پروژه هم‌چنان مسیر جایگزین است).
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 8 } }),
);
// افزودن پروژه به‌صورت درجا — بدون رفتن به صفحه‌ی پروژه‌ها
const addProjectInline = () => {
const name = newProj.trim();
if (!name) return;
const dup = projects.some((p) => normalize(p) === normalize(name));
if (dup) {
toast.error("این پروژه قبلاً ثبت شده است.");
return;
}
onAddProject(name);
setNewProj("");
toast.success("پروژه افزوده شد.");
};
const assignAll = (proj: string) => {
items.forEach((it) => onAssign(it.tempId, proj));
};
const onDragEnd = (e: DragEndEvent) => {
const tempId = (e.active.data.current as { tempId?: string; isAll?: boolean } | undefined)?.tempId;
const proj = (e.over?.data.current as { proj?: string } | undefined)?.proj;
if (!proj) return;
if (tempId === ALL_KEY) {
// همه‌ی اقلام به این پروژه
assignAll(proj);
} else if (tempId) {
onAssign(tempId, proj);
}
};
return (
<div data-field="project">
<h3 className="mb-1 text-[15px] font-bold">
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span>
پروژه‌ها
</h3>
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'یک تماس می‌تواند درباره‌ی چند پروژه باشد. هر قلم را روی پروژه‌ی مربوطه <b>بکشید و رها کنید</b> (یا روی قلم کلیک کنید، بعد روی پروژه کلیک کنید). هر قلم می‌تواند روی چند پروژه باشد. گزینه‌ی «همه اقلام» همه‌ی اقلام را به پروژه‌ی موردنظر اختصاص می‌دهد.' }} />
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
<div className="dd-panels">
<div className="dd-col dd-items-col">
<h4 className="mb-1 text-[13px] font-bold">اقلام</h4>
<div className="dd-hint mb-2 text-[11px] text-muted">
قلم را بکشید و روی پروژه رها کنید — یا روی قلم کلیک کنید، بعد روی پروژه کلیک کنید
</div>
{/* همه اقلام — اختصاص همه به یک پروژه */}
<DraggableItem
label="همه اقلام"
assignedCount={0}
assignedNames={[]}
picked={picked === ALL_KEY}
isAll
onPick={() => setPicked(picked === ALL_KEY ? null : ALL_KEY)}
/>
{items.map((it) => (
<DraggableItem
key={it.tempId}
item={it}
label={itemRowLabel(it)}
assignedCount={(assignment[it.tempId] || []).length}
assignedNames={assignment[it.tempId] || []}
picked={picked === it.tempId}
isAll={false}
onPick={() => setPicked(picked === it.tempId ? null : it.tempId)}
/>
))}
</div>
<div className="dd-col dd-projects-col">
<h4 className="mb-1 text-[13px] font-bold">پروژه‌ها</h4>
{projects.map((pname) => (
<DroppableProject
key={pname}
name={pname}
tags={items
.filter((it) => (assignment[it.tempId] || []).includes(pname))
.map((it) => ({ tempId: it.tempId, label: itemRowLabel(it) }))}
onTagRemove={(tempId) => onUnassign(tempId, pname)}
onPickProject={() => {
if (picked) {
if (picked === ALL_KEY) assignAll(pname);
else onAssign(picked, pname);
setPicked(null);
}
}}
/>
))}
{!projects.length && (
<div className="dd-empty">هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div>
)}
{/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها */}
<div className="mt-3 rounded-md border border-dashed border-line p-2">
<div className="mb-1.5 text-[11.5px] font-semibold text-muted">افزودن پروژه‌ی جدید:</div>
<div className="flex items-center gap-2">
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="نام پروژه"
autoComplete="off"
value={newProj}
onChange={(e) => setNewProj(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addProjectInline();
}
}}
/>
<button type="button" className="btn-line-file shrink-0 bg-accent text-white hover:bg-accent-dark" onClick={addProjectInline}>
+ افزودن
</button>
</div>
</div>
</div>
</div>
</DndContext>
</div>
);
}