feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { startCasdoorLogin } from "../../lib/casdoor";
|
||||
import { api } from "../../lib/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import type { Project } from "../../lib/types";
|
||||
|
||||
// ورود به سامانه — Casdoor (OIDC + PKCE) + ورود آزمایشی برای توسعه
|
||||
export function LoginPage() {
|
||||
const setToken = useAuthStore((s) => s.setToken);
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
const setDevUser = useAuthStore((s) => s.setDevUser);
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
// ورود آزمایشی: پروژهها از بکاند خوانده میشوند
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [devRole, setDevRole] = useState<"pm" | "admin" | "procurement_manager">("pm");
|
||||
const [devProjectId, setDevProjectId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void api.projects.list().then(setProjects).catch(() => setProjects([]));
|
||||
}, []);
|
||||
|
||||
const devProject = useMemo(
|
||||
() => projects.find((p) => p.id === devProjectId) || null,
|
||||
[projects, devProjectId],
|
||||
);
|
||||
|
||||
// Callback: ?code=...&state=...
|
||||
useEffect(() => {
|
||||
const code = params.get("code");
|
||||
if (!code) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const { exchangeCodeForToken } = await import("../../lib/casdoor");
|
||||
const tok = await exchangeCodeForToken(code);
|
||||
setToken(tok.access_token);
|
||||
// fetch profile from our backend
|
||||
const { apiV26 } = await import("../../lib/api");
|
||||
const profile = await apiV26.users.me();
|
||||
setUser(profile);
|
||||
toast.success(`خوش آمدید، ${profile.display_name}`);
|
||||
// مدیر پروژه → پنل انبار؛ بقیه → دفتر پیشنهادهای خرید
|
||||
navigate(profile.role === "pm" ? "/inventory" : "/");
|
||||
} catch (err) {
|
||||
toast.error(`خطا در ورود: ${err instanceof Error ? err.message : "خطای ناشناخته"}`);
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params]);
|
||||
|
||||
const devLogin = () => {
|
||||
setDevUser(devRole, devRole === "pm" ? devProjectId || null : null, devProject?.name || null);
|
||||
toast.success("ورود آزمایشی انجام شد — نقش: " + (devRole === "pm" ? "مدیر پروژه" : devRole === "procurement_manager" ? "مدیر تدارکات" : "ادمین"));
|
||||
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
||||
navigate(devRole === "pm" ? "/inventory" : "/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="ورود به سامانه" sub="احراز هویت با حساب سازمانی (Casdoor)" />
|
||||
<PageLayout className="flex max-w-md flex-col items-start gap-4">
|
||||
<div className="rounded-xl border border-line bg-panel p-4 shadow-sm">
|
||||
<h3 className="mb-1 text-[14px] font-bold">ورود با حساب سازمانی</h3>
|
||||
<p className="mb-3 text-[12.5px] leading-6 text-muted">
|
||||
برای ورود به سامانه تدارکات و انبار، با حساب سازمانی خود وارد شوید. پس از ورود، نقش شما
|
||||
(مدیر پروژه / مدیر تدارکات / ادمین) مشخص و دسترسیها بر اساس آن فعال میشود.
|
||||
</p>
|
||||
<Button onClick={() => void startCasdoorLogin()}>ورود با Casdoor</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-dashed border-accent/40 bg-blue-bg/20 p-4">
|
||||
<h3 className="mb-1 text-[14px] font-bold">ورود آزمایشی (توسعه)</h3>
|
||||
<p className="mb-3 text-[12.5px] leading-6 text-muted">
|
||||
بدون Casdoor وارد شوید — نقش و پروژه از طریق هدرهای توسعه به بکاند ارسال میشود
|
||||
(فقط برای توسعه و تست محلی).
|
||||
</p>
|
||||
<div className="grid gap-3">
|
||||
<div className="field">
|
||||
<label>نقش</label>
|
||||
<select
|
||||
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"
|
||||
value={devRole}
|
||||
onChange={(e) => setDevRole(e.target.value as "pm" | "admin")}
|
||||
>
|
||||
<option value="pm">مدیر پروژه (PM)</option>
|
||||
<option value="procurement_manager">مدیر تدارکات</option>
|
||||
<option value="admin">ادمین (فقطخواندنی)</option>
|
||||
</select>
|
||||
</div>
|
||||
{devRole === "pm" && (
|
||||
<div className="field">
|
||||
<label>پروژه</label>
|
||||
<select
|
||||
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"
|
||||
value={devProjectId}
|
||||
onChange={(e) => setDevProjectId(e.target.value)}
|
||||
>
|
||||
<option value="">— انتخاب پروژه —</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="green" onClick={devLogin} disabled={devRole === "pm" && !devProjectId}>
|
||||
ورود آزمایشی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Attachment, Milestone, Quote } from "../../lib/types";
|
||||
import { api, nextDashedId } from "../../lib/api";
|
||||
import { useMaterials, useProjects, useQuotes, useUpdateQuote, useSuppliers } from "../../hooks";
|
||||
import {
|
||||
activeItems,
|
||||
itemTermsheetIds,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
specsLabel,
|
||||
supplierById,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { faNum, fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
||||
import { FileList } from "../../components/FileList";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
|
||||
interface CardDraft {
|
||||
domId: string;
|
||||
isNew: boolean;
|
||||
milestones: Milestone[];
|
||||
label: string | null; // null = پیشفرض «گزینه N»
|
||||
}
|
||||
|
||||
export interface AcceptModalProps {
|
||||
quoteId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلمها + فاکتورها
|
||||
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const projects = useProjects();
|
||||
const updateQuote = useUpdateQuote();
|
||||
|
||||
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
|
||||
|
||||
const [cards, setCards] = useState<CardDraft[]>([]);
|
||||
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
||||
const [proformas, setProformas] = useState<Attachment[]>([]);
|
||||
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
// تخصیص هر قلم به پروژهها (نامها)
|
||||
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string[]>>({});
|
||||
// تاریخ و زمان پذیرش — پیشفرض الان
|
||||
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// وقتی quote باز شد، state اولیه را بساز
|
||||
useMemo(() => {
|
||||
if (!q) return;
|
||||
const offered = q.termsheets.filter((t) => t.status === "offered");
|
||||
setCards(
|
||||
offered.map((t) => ({ domId: t.id, isNew: false, milestones: JSON.parse(JSON.stringify(t.milestones)), label: t.label })),
|
||||
);
|
||||
const rem = new Set<string>();
|
||||
q.line_items.forEach((li) => {
|
||||
if (li.rejected) rem.add(li.id);
|
||||
});
|
||||
setRemovedItems(rem);
|
||||
setAssignments({});
|
||||
// پروژههای هر قلم — از تخصیصهای قبلی، وگرنه پروژههای سطح پیشنهاد
|
||||
const projSel: Record<string, string[]> = {};
|
||||
q.line_items.forEach((li) => {
|
||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||||
projSel[li.id] = names.length
|
||||
? names
|
||||
: q.projects && q.projects.length
|
||||
? [...q.projects]
|
||||
: q.project
|
||||
? [q.project]
|
||||
: [];
|
||||
});
|
||||
setItemProjectsSel(projSel);
|
||||
setReady(true);
|
||||
}, [q]);
|
||||
|
||||
// پیوستها مستقیماً از سرور (MinIO) خوانده میشوند — نه از state محلی
|
||||
useEffect(() => {
|
||||
if (!quoteId) return;
|
||||
let alive = true;
|
||||
void api.files
|
||||
.list(quoteId, "invoice")
|
||||
.then((list) => alive && setInvoices(list))
|
||||
.catch(() => alive && setInvoices([]));
|
||||
void api.files
|
||||
.list(quoteId, "proforma")
|
||||
.then((list) => alive && setProformas(list))
|
||||
.catch(() => alive && setProformas([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [quoteId]);
|
||||
|
||||
if (!q || !ready) return null;
|
||||
|
||||
const sup = supplierById(suppliers, q.supplier_id);
|
||||
const allItems = q.line_items || [];
|
||||
const active = activeItems(q);
|
||||
|
||||
// گزینههای پروژه — پروژههای ثبتشده + پروژههای این پیشنهاد + تخصیصهای قبلی قلمها
|
||||
const projectOptions = (() => {
|
||||
const set = new Set<string>();
|
||||
projects.forEach((p) => p.name && set.add(p.name));
|
||||
(q.projects || []).forEach((n) => set.add(n));
|
||||
if (q.project) set.add(q.project);
|
||||
allItems.forEach((li) => (li.projects || []).forEach((p) => p.name && set.add(p.name)));
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
})();
|
||||
|
||||
const cardLabel = (c: CardDraft, idx: number) => c.label || `گزینه ${faNum(idx + 1)}`;
|
||||
|
||||
const updateCard = (domId: string, patch: Partial<CardDraft>) => {
|
||||
setCards((cs) => cs.map((c) => (c.domId === domId ? { ...c, ...patch } : c)));
|
||||
};
|
||||
|
||||
const addNewCard = () => {
|
||||
setCards((cs) => [...cs, { domId: "new-ts-" + Date.now(), isNew: true, milestones: [{ when_iso: null, amount: null }], label: null }]);
|
||||
};
|
||||
|
||||
const removeCard = (domId: string) => {
|
||||
setCards((cs) => cs.filter((c) => c.domId !== domId));
|
||||
setAssignments((a) => {
|
||||
const next: Record<string, string> = {};
|
||||
Object.entries(a).forEach(([liId, dom]) => {
|
||||
if (dom !== domId) next[liId] = dom;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const assign = (liId: string, domId: string) => {
|
||||
setAssignments((a) => ({ ...a, [liId]: domId }));
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
if (!q) return;
|
||||
|
||||
// پچ نهایی — همهی تغییرات روی همین کلون اعمال میشود (نه روی آبجکت store)
|
||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||
|
||||
// فقط کارتهایی با حداقل یک مرحلهی پرداخت
|
||||
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
|
||||
cards.forEach((c, idx) => {
|
||||
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
|
||||
if (!milestones.length) return;
|
||||
const existing = next.termsheets.find((t) => t.id === c.domId);
|
||||
cardMeta.set(c.domId, {
|
||||
milestones,
|
||||
label: c.label || `گزینه ${faNum(idx + 1)}`,
|
||||
existing,
|
||||
});
|
||||
});
|
||||
|
||||
if (!cardMeta.size) {
|
||||
toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است.");
|
||||
return;
|
||||
}
|
||||
|
||||
// رد کردن قلمها — در داده میمانند تا با بازگشت وضعیت برگردند
|
||||
next.line_items.forEach((li) => {
|
||||
if (removedItems.has(li.id)) li.rejected = true;
|
||||
});
|
||||
const active = activeItems(next);
|
||||
if (!active.length) {
|
||||
toast.error("همهی قلمها رد شدهاند؛ حداقل یک قلم لازم است.");
|
||||
return;
|
||||
}
|
||||
|
||||
// فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته میشوند
|
||||
const fallbackDom = [...cardMeta.keys()][0];
|
||||
const chosenDomIds = new Set<string>();
|
||||
active.forEach((li) => {
|
||||
const dom = assignments[li.id] || fallbackDom;
|
||||
if (dom && cardMeta.has(dom)) chosenDomIds.add(dom);
|
||||
});
|
||||
|
||||
const domToFinal: Record<string, string> = {};
|
||||
chosenDomIds.forEach((dom) => {
|
||||
const meta = cardMeta.get(dom)!;
|
||||
if (meta.existing) {
|
||||
meta.existing.milestones = meta.milestones;
|
||||
meta.existing.status = "accepted";
|
||||
domToFinal[dom] = dom;
|
||||
} else {
|
||||
const newId = nextDashedId("ts", next.termsheets);
|
||||
next.termsheets.push({ id: newId, label: meta.label, milestones: meta.milestones, status: "accepted" });
|
||||
domToFinal[dom] = newId;
|
||||
}
|
||||
});
|
||||
|
||||
// قلمها: اتصالهای offered حفظ میشوند + جفت پذیرفتهشده ثبت میشود
|
||||
active.forEach((li) => {
|
||||
const dom = assignments[li.id] || fallbackDom;
|
||||
const finalId = domToFinal[dom];
|
||||
const current = itemTermsheetIds(li).filter((tid) => next.termsheets.some((t) => t.id === tid));
|
||||
if (finalId && !current.includes(finalId)) current.push(finalId);
|
||||
li.termsheet_ids = current.length ? current : finalId ? [finalId] : current;
|
||||
li.accepted_ts_id = finalId || null;
|
||||
});
|
||||
|
||||
// termsheet پذیرفتهشدهای که هیچ قلمی انتخابش نکرده → برمیگردد به offered
|
||||
const usedIds = new Set<string>();
|
||||
active.forEach((li) => {
|
||||
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
|
||||
});
|
||||
next.termsheets.forEach((t) => {
|
||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||
});
|
||||
|
||||
// پروژههای هر قلم + همگامسازی پروژههای سطح پیشنهاد
|
||||
const projUnion = new Set<string>();
|
||||
next.line_items.forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const names = itemProjectsSel[li.id];
|
||||
if (names === undefined) return;
|
||||
li.projects = names.map((n) => ({ id: "", name: n }));
|
||||
li.projects.forEach((p) => projUnion.add(p.name));
|
||||
});
|
||||
next.projects = [...projUnion];
|
||||
next.project = next.projects[0] || "";
|
||||
|
||||
const now = new Date().toISOString();
|
||||
next.accepted_date = acceptDate || now;
|
||||
next.status = "accepted";
|
||||
next.events.push({ type: "accepted", occurred_at: now });
|
||||
next.invoices = invoices;
|
||||
next.proformas = proformas;
|
||||
|
||||
void updateQuote.mutateAsync({ id: q.id, patch: next });
|
||||
setReady(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>پذیرش پیشنهاد — {sup.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-[12px] text-muted">
|
||||
شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید.
|
||||
</div>
|
||||
|
||||
{/* کارتهای termsheet */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{cards.map((c, idx) => (
|
||||
<div key={c.domId} className="modal-variant">
|
||||
<div className="modal-variant-head">
|
||||
<span className="variant-num">{cardLabel(c, idx)}</span>
|
||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeCard(c.domId)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{c.milestones.map((m, i) => (
|
||||
<MilestoneRow
|
||||
key={i}
|
||||
milestone={m}
|
||||
onChange={(nextM) => {
|
||||
const ms = [...c.milestones];
|
||||
ms[i] = nextM;
|
||||
updateCard(c.domId, { milestones: ms });
|
||||
}}
|
||||
onRemove={() => updateCard(c.domId, { milestones: c.milestones.filter((_, j) => j !== i) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="line" size="sm" className="mt-1.5" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
|
||||
+ مرحله پرداخت
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="line" size="sm" className="self-start" onClick={addNewCard}>
|
||||
+ نوشتن conditions جدید
|
||||
</Button>
|
||||
|
||||
{/* تخصیص قلمها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">تخصیص قلمها به conditions:</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
قلمی که میخرید را تیک بزنید؛ قلمی که نمیخرید را بدون تیک رها کنید تا از معامله حذف شود.
|
||||
</div>
|
||||
<div>
|
||||
{allItems.map((li) => {
|
||||
const removed = removedItems.has(li.id);
|
||||
const m = materialById(materials, li.material_id);
|
||||
const specText = specsLabel(materialSpecs(m));
|
||||
return (
|
||||
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={!removed}
|
||||
title="تیک = در معامله میماند"
|
||||
onChange={(e) => {
|
||||
const next = new Set(removedItems);
|
||||
if (e.target.checked) next.delete(li.id);
|
||||
else next.add(li.id);
|
||||
setRemovedItems(next);
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 text-[13px]">
|
||||
{m.category} {m.brand}
|
||||
{specText ? ` · ${specText}` : ""}
|
||||
</span>
|
||||
<span className="text-[12px] text-muted">
|
||||
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
|
||||
</span>
|
||||
<select
|
||||
className="min-w-[140px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
|
||||
value={assignments[li.id] || cards[0]?.domId || ""}
|
||||
onChange={(e) => assign(li.id, e.target.value)}
|
||||
>
|
||||
{cards.map((c, idx) => (
|
||||
<option key={c.domId} value={c.domId}>
|
||||
{cardLabel(c, idx)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{active.length === 0 && (
|
||||
<div className="mb-1 text-[12px] text-red">همهی قلمها رد شدهاند — حداقل یک قلم را تیک بزنید.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* تخصیص قلمها به پروژهها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">تخصیص قلمها به پروژهها:</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
برای هر متریال مشخص کنید به کدام پروژه (ها) تعلق میگیرد.
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{allItems
|
||||
.filter((li) => !removedItems.has(li.id))
|
||||
.map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
const specText = specsLabel(materialSpecs(m));
|
||||
return (
|
||||
<div key={li.id} className="rounded-lg border border-line p-2.5">
|
||||
<div className="mb-1.5 text-[12.5px]">
|
||||
<span className="font-semibold">
|
||||
{m.category} {m.brand}
|
||||
</span>
|
||||
{specText ? <span className="text-muted"> · {specText}</span> : ""}
|
||||
<span className="ms-2 text-[11px] text-muted">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
</span>
|
||||
</div>
|
||||
<MultiSelectCombobox
|
||||
values={itemProjectsSel[li.id] || []}
|
||||
onChange={(names) => setItemProjectsSel((s) => ({ ...s, [li.id]: names }))}
|
||||
options={projectOptions}
|
||||
placeholder="انتخاب پروژه..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* فاکتورهای اصلی */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
چند فاکتور را با هم انتخاب کنید؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره میشود.
|
||||
</div>
|
||||
<FileList
|
||||
files={invoices}
|
||||
onChange={setInvoices}
|
||||
upload={(f) => api.files.upload(q.id, "invoice", f)}
|
||||
onDelete={(a) =>
|
||||
a.id &&
|
||||
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
|
||||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
||||
)
|
||||
}
|
||||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* پیشفاکتورها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">پیشفاکتورها (چند فایل):</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
پیشفاکتورهای دریافتی از تأمینکننده؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره میشود.
|
||||
</div>
|
||||
<FileList
|
||||
files={proformas}
|
||||
onChange={setProformas}
|
||||
upload={(f) => api.files.upload(q.id, "proforma", f)}
|
||||
onDelete={(a) =>
|
||||
a.id &&
|
||||
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
|
||||
toast.error(err instanceof Error ? err.message : "حذف پیشفاکتور ناموفق بود"),
|
||||
)
|
||||
}
|
||||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)}
|
||||
label="افزودن پیشفاکتور"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* تاریخ و زمان پذیرش */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="field max-w-xs">
|
||||
<label>تاریخ و زمان پذیرش</label>
|
||||
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="slate" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={confirm}>ثبت معامله بستهشده</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Package } from "lucide-react";
|
||||
import type { Quote } from "../../lib/types";
|
||||
import { useMaterials, useQuotes, useSuppliers, useUpdateQuote } from "../../hooks";
|
||||
import { activeItems, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
||||
import { fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
|
||||
export interface ArriveModalProps {
|
||||
quoteId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// مودال رسید: تیک بزنید کدام قلمها رسیدهاند؛ تا همه نرسیده باشند quote پذیرفته میماند
|
||||
export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const updateQuote = useUpdateQuote();
|
||||
|
||||
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
|
||||
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
// تاریخ و زمان رسید — پیشفرض الان
|
||||
const [arriveDate, setArriveDate] = useState<string | null>(() => new Date().toISOString());
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useMemo(() => {
|
||||
if (!q) return;
|
||||
const set = new Set<string>();
|
||||
activeItems(q).forEach((li) => {
|
||||
if (li.arrived_date) set.add(li.id);
|
||||
});
|
||||
setChecked(set);
|
||||
setReady(true);
|
||||
}, [q]);
|
||||
|
||||
if (!q || !ready) return null;
|
||||
|
||||
const sup = supplierById(suppliers, q.supplier_id);
|
||||
const items = activeItems(q);
|
||||
|
||||
const confirm = () => {
|
||||
if (!checked.size) {
|
||||
toast.error("حداقل یک قلم را برای رسید انتخاب کنید.");
|
||||
return;
|
||||
}
|
||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||
const arrivedIso = arriveDate || new Date().toISOString();
|
||||
const now = new Date().toISOString();
|
||||
next.line_items.forEach((li) => {
|
||||
li.arrived_date = checked.has(li.id) ? arrivedIso : null;
|
||||
});
|
||||
if (activeItems(next).every((li) => li.arrived_date)) {
|
||||
next.status = "arrived";
|
||||
next.arrived_date = arrivedIso;
|
||||
next.events.push({ type: "arrived", occurred_at: now });
|
||||
} else {
|
||||
next.arrived_date = null;
|
||||
}
|
||||
void updateQuote.mutateAsync({ id: q.id, patch: next });
|
||||
setReady(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>ثبت رسید — {sup.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-[12px] text-muted">
|
||||
کدام قلمها رسیدهاند؟ تیک بزنید و ثبت کنید. تا وقتی همهی قلمها نرسیدهاند، پیشنهاد
|
||||
«پذیرفته شده» میماند.
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{items.map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
const specText = materialSpecs(m).join(" · ");
|
||||
return (
|
||||
<label key={li.id} className={`arrive-row${li.arrived_date ? " arrived" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-green"
|
||||
checked={checked.has(li.id)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(checked);
|
||||
if (e.target.checked) next.add(li.id);
|
||||
else next.delete(li.id);
|
||||
setChecked(next);
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
|
||||
{m.category} {m.brand}
|
||||
{specText ? ` · ${specText}` : ""}
|
||||
</span>
|
||||
<span className="arrive-meta">
|
||||
{fmtNum(li.quantity)} {li.unit || ""} × {groupCommas(li.unit_price, true)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* تاریخ و زمان رسید */}
|
||||
<div className="field max-w-xs">
|
||||
<label>تاریخ و زمان رسید</label>
|
||||
<JalaliDatePicker value={arriveDate} onChange={setArriveDate} placeholder="تاریخ و ساعت" withTime />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="slate" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button variant="green" onClick={confirm}>
|
||||
ثبت رسید
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
|
||||
import {
|
||||
useMaterials,
|
||||
useProjects,
|
||||
useQuotes,
|
||||
useStoreData,
|
||||
useSuppliers,
|
||||
useUnits,
|
||||
} from "../../hooks";
|
||||
import { useAppStore } from "../../store";
|
||||
import { filterQuotes } from "../../lib/quoteUtils";
|
||||
import { daysBetween } from "../../lib/formatters";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { StatsBar } from "../../components/StatsBar";
|
||||
import { FilterToolbar, type Filters } from "./FilterToolbar";
|
||||
import { LedgerTable } from "./LedgerTable";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { useLogout } from "../Inventory/panelMenu";
|
||||
import { AcceptModal } from "./AcceptModal";
|
||||
import { EditModal } from "./EditModal";
|
||||
|
||||
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" };
|
||||
|
||||
// صفحهی اصلی دفتر پیشنهادهای خرید
|
||||
export function DashboardPage() {
|
||||
useStoreData();
|
||||
const logout = useLogout();
|
||||
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const projects = useProjects();
|
||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
||||
|
||||
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
|
||||
const toggleExpand = useAppStore((s) => s.toggleExpand);
|
||||
|
||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
||||
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
|
||||
const [acceptId, setAcceptId] = useState<string | null>(null);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
|
||||
const store = useMemo(
|
||||
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions }),
|
||||
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions],
|
||||
);
|
||||
|
||||
// فیلتر + مرتبسازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمیشوند
|
||||
const filtered = useMemo(() => {
|
||||
const list = filterQuotes(store, filters);
|
||||
const createdTs = (q: (typeof store.quotes)[number]) => {
|
||||
const iso = q.created_at || q.quote_date;
|
||||
const ts = iso ? new Date(iso).getTime() : 0;
|
||||
return Number.isFinite(ts) ? ts : 0;
|
||||
};
|
||||
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
|
||||
}, [store, filters, sortDir]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = quotes.length;
|
||||
const offered = quotes.filter((q) => q.status === "offered").length;
|
||||
const accepted = quotes.filter((q) => q.status === "accepted").length;
|
||||
const leadTimes = quotes
|
||||
.filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date)
|
||||
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
||||
.filter((x): x is number => x != null);
|
||||
const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null;
|
||||
return { total, offered, accepted, avgLead };
|
||||
}, [quotes]);
|
||||
|
||||
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
||||
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="دفتر پیشنهادهای خرید"
|
||||
sub="دادهها در پایگاه داده ذخیره میشوند"
|
||||
brand
|
||||
menu={
|
||||
<PanelMenu
|
||||
title="پنلها"
|
||||
items={[
|
||||
{ label: "مدیریت انبار", to: "/inventory" },
|
||||
{ label: "مدیریت تدارکات", to: "/", active: true },
|
||||
{ divider: true },
|
||||
{ label: "خروج از حساب", onClick: logout },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Link to="/suppliers" className="btn-line-file no-underline">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
تأمینکنندهها
|
||||
</Link>
|
||||
<Link to="/projects" className="btn-line-file no-underline">
|
||||
<FolderKanban className="h-3.5 w-3.5" />
|
||||
پروژهها
|
||||
</Link>
|
||||
<Link to="/units" className="btn-line-file no-underline">
|
||||
<Ruler className="h-3.5 w-3.5" />
|
||||
ضرایب تبدیل
|
||||
</Link>
|
||||
<Link to="/wizard" className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
افزودن پیشنهاد جدید
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
<StatsBar
|
||||
total={stats.total}
|
||||
offered={stats.offered}
|
||||
accepted={stats.accepted}
|
||||
avgLeadTime={stats.avgLead}
|
||||
/>
|
||||
<FilterToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
categories={categoryOptions}
|
||||
suppliers={supplierOptions}
|
||||
sortDir={sortDir}
|
||||
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
|
||||
/>
|
||||
<LedgerTable
|
||||
store={store}
|
||||
quotes={filtered}
|
||||
expanded={expandedQuoteIds}
|
||||
onToggleExpand={toggleExpand}
|
||||
onAccept={setAcceptId}
|
||||
onEdit={setEditId}
|
||||
/>
|
||||
</PageLayout>
|
||||
|
||||
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
|
||||
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
|
||||
import { api, nextDashedId, nextId } from "../../lib/api";
|
||||
import {
|
||||
useMaterials,
|
||||
useProjects,
|
||||
useQuotes,
|
||||
useSuppliers,
|
||||
useUpdateQuote,
|
||||
useAddSupplier,
|
||||
useAddMaterial,
|
||||
} from "../../hooks";
|
||||
import { itemTermsheetIds, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
||||
import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { specsKey } from "../../lib/quoteUtils";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { NumberInput } from "../../components/NumberInput";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { FileList } from "../../components/FileList";
|
||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
|
||||
interface EditItemRow {
|
||||
key: string;
|
||||
origLi?: LineItem;
|
||||
cat: string;
|
||||
brand: string;
|
||||
specs: string[];
|
||||
qty: number | null;
|
||||
unit: string;
|
||||
price: number | null;
|
||||
total: number | null;
|
||||
tsDomIds: string[];
|
||||
acceptedTsId: string | null;
|
||||
rejected: boolean;
|
||||
restore: boolean;
|
||||
}
|
||||
|
||||
interface EditTsCard {
|
||||
key: string;
|
||||
domId: string; // "" یا "new-ts-..." برای کارت جدید
|
||||
milestones: Milestone[];
|
||||
}
|
||||
|
||||
export interface EditModalProps {
|
||||
quoteId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// مودال ویرایش کامل پیشنهاد
|
||||
export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const projects = useProjects();
|
||||
const updateQuote = useUpdateQuote();
|
||||
const addSupplier = useAddSupplier();
|
||||
const addMaterial = useAddMaterial();
|
||||
|
||||
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
const [supplierName, setSupplierName] = useState("");
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
||||
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
||||
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
||||
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
||||
const [expiryDate, setExpiryDate] = useState<string | null>(null);
|
||||
const [acceptedDate, setAcceptedDate] = useState<string | null>(null);
|
||||
const [arrivedDate, setArrivedDate] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState("offered");
|
||||
|
||||
// گزینههای مشخصه — همهی مشخصههای ثبتشده (کش)
|
||||
const specOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
materials.forEach((m) => (m.specs || []).forEach((s) => s && set.add(s)));
|
||||
itemRows.forEach((r) => r.specs.forEach((s) => s && set.add(s)));
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [materials, itemRows]);
|
||||
|
||||
// گزینههای پروژه — پروژههای ثبتشده + پروژههای خود پیشنهاد
|
||||
const projectOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
projects.forEach((p) => p.name && set.add(p.name));
|
||||
if (q) {
|
||||
(q.projects || []).forEach((n) => set.add(n));
|
||||
if (q.project) set.add(q.project);
|
||||
}
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [projects, q]);
|
||||
|
||||
useMemo(() => {
|
||||
if (!q) return;
|
||||
const sup = supplierById(suppliers, q.supplier_id);
|
||||
setSupplierName(sup.name);
|
||||
setSelectedProjects(q.projects && q.projects.length ? [...q.projects] : q.project ? [q.project] : []);
|
||||
setNotes(q.notes || "");
|
||||
setItemRows(
|
||||
q.line_items.map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
return {
|
||||
key: li.id,
|
||||
origLi: li,
|
||||
cat: m.category || "",
|
||||
brand: m.brand || "",
|
||||
specs: materialSpecs(m),
|
||||
qty: li.quantity,
|
||||
unit: li.unit || "",
|
||||
price: li.unit_price,
|
||||
total: li.total_price,
|
||||
tsDomIds: itemTermsheetIds(li).filter((tid) => q.termsheets.some((t) => t.id === tid)),
|
||||
acceptedTsId: li.accepted_ts_id,
|
||||
rejected: !!li.rejected,
|
||||
restore: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
setTsCards(
|
||||
q.termsheets.map((t) => ({
|
||||
key: t.id,
|
||||
domId: t.id,
|
||||
milestones: t.milestones.map((m) => ({ ...m })),
|
||||
})),
|
||||
);
|
||||
setQuoteDate(q.quote_date || null);
|
||||
setExpiryDate(q.expiry_date || null);
|
||||
setAcceptedDate(q.accepted_date || null);
|
||||
setArrivedDate(q.arrived_date || null);
|
||||
setStatus(q.status);
|
||||
setReady(true);
|
||||
}, [q, suppliers, materials]);
|
||||
|
||||
// فاکتورها مستقیماً از سرور (MinIO) خوانده میشوند — نه از state محلی
|
||||
useEffect(() => {
|
||||
if (!quoteId) return;
|
||||
let alive = true;
|
||||
void api.files
|
||||
.list(quoteId, "invoice")
|
||||
.then((list) => alive && setInvoices(list))
|
||||
.catch(() => alive && setInvoices([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [quoteId]);
|
||||
|
||||
if (!q || !ready) return null;
|
||||
|
||||
const tsLabel = (card: EditTsCard, idx: number) => {
|
||||
const orig = q.termsheets.find((t) => t.id === card.domId);
|
||||
return orig?.label || `گزینه ${faNum(idx + 1)}`;
|
||||
};
|
||||
|
||||
const patchItem = (key: string, patch: Partial<EditItemRow>) => {
|
||||
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
const addItemRow = () => {
|
||||
setItemRows((rows) => [
|
||||
...rows,
|
||||
{
|
||||
key: "new-" + Math.random().toString(36).slice(2, 9),
|
||||
cat: "",
|
||||
brand: "",
|
||||
specs: [],
|
||||
qty: null,
|
||||
unit: "",
|
||||
price: null,
|
||||
total: null,
|
||||
tsDomIds: [],
|
||||
acceptedTsId: null,
|
||||
rejected: false,
|
||||
restore: true,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const updateTs = (key: string, patch: Partial<EditTsCard>) => {
|
||||
setTsCards((cards) => cards.map((c) => (c.key === key ? { ...c, ...patch } : c)));
|
||||
};
|
||||
|
||||
const addTsCard = () => {
|
||||
setTsCards((cards) => [
|
||||
...cards,
|
||||
{ key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeTsCard = (key: string) => {
|
||||
setTsCards((cards) => cards.filter((c) => c.key !== key));
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||
|
||||
// تأمینکننده — موجود یا جدید
|
||||
if (supplierName.trim()) {
|
||||
const norm = normalize(supplierName);
|
||||
const exist = suppliers.find((s) => normalize(s.name) === norm);
|
||||
if (exist) {
|
||||
next.supplier_id = exist.id;
|
||||
} else {
|
||||
const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] };
|
||||
const created = await addSupplier.mutateAsync(newSup);
|
||||
next.supplier_id = created.id; // UUID واقعی از بکاند
|
||||
}
|
||||
}
|
||||
|
||||
next.project = selectedProjects[0] || "";
|
||||
next.projects = [...selectedProjects];
|
||||
next.notes = notes.trim();
|
||||
next.invoices = invoices;
|
||||
|
||||
// termsheetها — id موجود حفظ میشود، جدیدها ساخته میشوند
|
||||
const tsIdMap: Record<string, string> = {};
|
||||
const newTermsheets: Termsheet[] = [];
|
||||
tsCards.forEach((card, i) => {
|
||||
const domId = card.domId || "new-ts-" + i;
|
||||
const realId =
|
||||
domId && !domId.startsWith("new-ts-")
|
||||
? domId
|
||||
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
|
||||
tsIdMap[domId] = realId;
|
||||
const orig = next.termsheets.find((t) => t.id === realId);
|
||||
const label = orig?.label || `گزینه ${faNum(i + 1)}`;
|
||||
const st = orig?.status || "offered";
|
||||
newTermsheets.push({ id: realId, label, milestones: card.milestones, status: st });
|
||||
});
|
||||
next.termsheets = newTermsheets;
|
||||
|
||||
// اقلام
|
||||
const newItems: LineItem[] = [];
|
||||
const materialsCopy = [...materials];
|
||||
for (const row of itemRows) {
|
||||
const cat = row.cat.trim();
|
||||
const brand = row.brand.trim();
|
||||
const specs = row.specs;
|
||||
const unit = row.unit.trim();
|
||||
const qty = row.qty;
|
||||
const price = row.price;
|
||||
const total = row.total;
|
||||
if (!cat && !brand && qty == null && price == null) continue; // ردیف خالی
|
||||
|
||||
let material = materialsCopy.find(
|
||||
(m) =>
|
||||
normalize(m.category) === normalize(cat) &&
|
||||
normalize(m.brand) === normalize(brand) &&
|
||||
specsKey(m.specs) === specsKey(specs),
|
||||
);
|
||||
let materialId: string;
|
||||
if (material) {
|
||||
materialId = material.id;
|
||||
} else {
|
||||
const nm = {
|
||||
id: nextId("m", materialsCopy),
|
||||
name: [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید",
|
||||
topic: materialById(materials, row.origLi?.material_id || "").topic || "",
|
||||
category: cat,
|
||||
brand,
|
||||
specs,
|
||||
base_unit: unit,
|
||||
};
|
||||
materialsCopy.push(nm);
|
||||
// منتظر ساخت متریال میمانیم تا id واقعی (UUID) به قلم وصل شود
|
||||
const created = await addMaterial.mutateAsync(nm);
|
||||
materialId = created.id;
|
||||
}
|
||||
|
||||
const wasRejected = row.rejected;
|
||||
const rejected = wasRejected && !row.restore;
|
||||
|
||||
const termsheet_ids: string[] = [];
|
||||
let acceptedTsId = row.acceptedTsId;
|
||||
if (!rejected) {
|
||||
row.tsDomIds.forEach((domId) => {
|
||||
const realId = tsIdMap[domId] || domId;
|
||||
if (realId) termsheet_ids.push(realId);
|
||||
});
|
||||
if (!termsheet_ids.length && next.termsheets.length) {
|
||||
termsheet_ids.push(next.termsheets[0].id);
|
||||
}
|
||||
if (wasRejected) {
|
||||
const acc = next.termsheets.find((t) => t.status === "accepted");
|
||||
if (acc) {
|
||||
termsheet_ids.length = 0;
|
||||
termsheet_ids.push(acc.id);
|
||||
}
|
||||
acceptedTsId = acc ? acc.id : null;
|
||||
}
|
||||
} else {
|
||||
acceptedTsId = null;
|
||||
}
|
||||
|
||||
newItems.push({
|
||||
id: "li-" + (newItems.length + 1),
|
||||
material_id: materialId,
|
||||
quantity: qty,
|
||||
unit,
|
||||
unit_price: price,
|
||||
total_price: total,
|
||||
termsheet_ids,
|
||||
accepted_ts_id: acceptedTsId,
|
||||
rejected,
|
||||
arrived_date: row.origLi?.arrived_date ?? null,
|
||||
projects: row.origLi?.projects ?? [],
|
||||
});
|
||||
}
|
||||
next.line_items = newItems.length ? newItems : next.line_items;
|
||||
|
||||
// ترمشیت بدون قلم بیمعنی است — حذف
|
||||
const referencedTs = new Set<string>();
|
||||
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
|
||||
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
|
||||
next.line_items.forEach((li) => {
|
||||
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
|
||||
li.accepted_ts_id = null;
|
||||
}
|
||||
});
|
||||
|
||||
// تاریخها و وضعیت
|
||||
next.quote_date = quoteDate || q.quote_date;
|
||||
next.expiry_date = expiryDate || "";
|
||||
next.accepted_date = acceptedDate;
|
||||
next.arrived_date = arrivedDate;
|
||||
next.status = status as Quote["status"];
|
||||
|
||||
// پاکسازی خودکار در بازگشت وضعیت
|
||||
if (next.status === "offered") {
|
||||
next.accepted_date = null;
|
||||
next.arrived_date = null;
|
||||
next.termsheets.forEach((t) => {
|
||||
t.status = "offered";
|
||||
});
|
||||
next.line_items.forEach((li) => {
|
||||
li.rejected = false;
|
||||
li.arrived_date = null;
|
||||
li.accepted_ts_id = null;
|
||||
});
|
||||
} else if (next.status === "accepted") {
|
||||
next.arrived_date = null;
|
||||
next.line_items.forEach((li) => {
|
||||
li.arrived_date = null;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await updateQuote.mutateAsync({ id: q.id, patch: next });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "ذخیرهی تغییرات ناموفق بود");
|
||||
return;
|
||||
}
|
||||
setReady(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>ویرایش پیشنهاد</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label>تأمینکننده</label>
|
||||
<Combobox
|
||||
value={supplierName}
|
||||
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
|
||||
items={suppliers.map((s) => ({ id: s.id, name: s.name }))}
|
||||
allowAdd
|
||||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||||
placeholder="نام تأمینکننده"
|
||||
onInputChange={(v) => setSupplierName(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>پروژه</label>
|
||||
<MultiSelectCombobox
|
||||
values={selectedProjects}
|
||||
onChange={setSelectedProjects}
|
||||
options={projectOptions}
|
||||
placeholder="انتخاب پروژه..."
|
||||
/>
|
||||
</div>
|
||||
<div className="field col-span-2">
|
||||
<label>یادداشت</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* اقلام */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{itemRows.map((row) => (
|
||||
<div key={row.key} className={`rounded-lg border p-3 ${row.rejected ? "border-dashed border-line opacity-60" : "border-line"}`}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div className="field">
|
||||
<label>متریال</label>
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="متریال"
|
||||
value={row.cat}
|
||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>برند</label>
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="برند"
|
||||
value={row.brand}
|
||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>مشخصه</label>
|
||||
<MultiSelectCombobox
|
||||
values={row.specs}
|
||||
onChange={(specs) => patchItem(row.key, { specs })}
|
||||
options={specOptions}
|
||||
placeholder="مشخصه"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>واحد</label>
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="واحد"
|
||||
value={row.unit}
|
||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تعداد</label>
|
||||
<NumberInput
|
||||
value={row.qty}
|
||||
onChange={(qty) => {
|
||||
patchItem(row.key, {
|
||||
qty,
|
||||
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
|
||||
});
|
||||
}}
|
||||
placeholder="تعداد"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>قیمت واحد</label>
|
||||
<NumberInput
|
||||
value={row.price}
|
||||
onChange={(price) => {
|
||||
patchItem(row.key, {
|
||||
price,
|
||||
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت واحد"
|
||||
maxDecimals={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>قیمت کل</label>
|
||||
<NumberInput
|
||||
value={row.total}
|
||||
onChange={(t) => {
|
||||
patchItem(row.key, {
|
||||
total: t,
|
||||
price:
|
||||
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت کل"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[12px] text-muted transition-colors hover:bg-red-bg hover:text-red"
|
||||
title="حذف قلم"
|
||||
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* تخصیص termsheet / بازگرداندن */}
|
||||
<div className="mt-2.5 border-t border-line pt-2">
|
||||
{row.rejected ? (
|
||||
<label className="ts-cb-label e-restore">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.restore}
|
||||
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
||||
/>
|
||||
بازگرداندن به معامله
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tsCards.map((card, idx) => (
|
||||
<label key={card.key} className="chip cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="me-1 accent-accent"
|
||||
checked={row.tsDomIds.includes(card.domId || card.key)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(row.tsDomIds);
|
||||
const domId = card.domId || card.key;
|
||||
if (e.target.checked) next.add(domId);
|
||||
else next.delete(domId);
|
||||
patchItem(row.key, { tsDomIds: [...next] });
|
||||
}}
|
||||
/>
|
||||
{tsLabel(card, idx)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
|
||||
+ افزودن قلم
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* termsheetها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{tsCards.map((card, idx) => (
|
||||
<div key={card.key} className="variant-card">
|
||||
<div className="variant-head">
|
||||
<span className="variant-num">{tsLabel(card, idx)}</span>
|
||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-1 flex flex-col gap-1.5">
|
||||
{card.milestones.map((ms, i) => (
|
||||
<MilestoneRow
|
||||
key={i}
|
||||
milestone={ms}
|
||||
onChange={(nextMs) =>
|
||||
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
|
||||
}
|
||||
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="line"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
||||
>
|
||||
+ مرحله پرداخت
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
|
||||
+ افزودن termsheet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* فاکتورها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
چند فاکتور را با هم انتخاب کنید؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره میشود.
|
||||
</div>
|
||||
<FileList
|
||||
files={invoices}
|
||||
onChange={setInvoices}
|
||||
upload={(f) => api.files.upload(q.id, "invoice", f)}
|
||||
onDelete={(a) =>
|
||||
a.id &&
|
||||
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
|
||||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
||||
)
|
||||
}
|
||||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* تاریخها و وضعیت */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[12px] text-muted">تاریخها و وضعیت:</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label>تاریخ پیشنهاد</label>
|
||||
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ انقضا</label>
|
||||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ پذیرش</label>
|
||||
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ رسید</label>
|
||||
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="field col-span-2">
|
||||
<label>وضعیت (فقط بازگشت به مرحلههای قبل)</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
{statusOptionsFor(q.status).map((st) => (
|
||||
<option key={st} value={st}>
|
||||
{STATUS_LABEL[st]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="slate" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={() => void confirm()}>ذخیره تغییرات</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
|
||||
export interface Filters {
|
||||
category: string;
|
||||
supplier: string;
|
||||
status: string;
|
||||
search: string;
|
||||
}
|
||||
|
||||
export interface FilterToolbarProps {
|
||||
filters: Filters;
|
||||
onChange: (f: Filters) => void;
|
||||
categories: string[];
|
||||
suppliers: { id: string; name: string }[];
|
||||
sortDir: "desc" | "asc";
|
||||
onToggleSort: () => void;
|
||||
}
|
||||
|
||||
// نوار فیلتر دفتر: ۳ کامبوباکس + جستجوی آزاد + دکمهی مرتبسازی ⇅
|
||||
export function FilterToolbar({ filters, onChange, categories, suppliers, sortDir, onToggleSort }: FilterToolbarProps) {
|
||||
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="field min-w-[150px] flex-1">
|
||||
<label>دسته</label>
|
||||
<Combobox
|
||||
value={filters.category}
|
||||
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
|
||||
items={categories}
|
||||
allowAdd={false}
|
||||
placeholder="همه"
|
||||
onInputChange={(v) => set({ category: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[170px] flex-1">
|
||||
<label>تأمینکننده</label>
|
||||
<Combobox
|
||||
value={filters.supplier}
|
||||
onChange={(it: { id: string; label: string }) => set({ supplier: it.label })}
|
||||
items={suppliers}
|
||||
allowAdd={false}
|
||||
placeholder="همه"
|
||||
onInputChange={(v) => set({ supplier: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[150px] flex-1">
|
||||
<label>وضعیت</label>
|
||||
<Combobox
|
||||
value={filters.status}
|
||||
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
||||
items={["پیشنهاد شده", "پذیرفته شده", "رسیده"]}
|
||||
allowAdd={false}
|
||||
placeholder="همه"
|
||||
onInputChange={(v) => set({ status: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[200px] flex-1">
|
||||
<label>جستجو</label>
|
||||
<input
|
||||
type="search"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm shadow-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="جستجو در نام/برند/پروژه..."
|
||||
value={filters.search}
|
||||
onChange={(e) => set({ search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[140px]">
|
||||
<label>ترتیب</label>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-line bg-panel px-3 text-sm shadow-sm hover:bg-slate-bg"
|
||||
onClick={onToggleSort}
|
||||
title={sortDir === "desc" ? "تغییر به قدیمیترین اول" : "تغییر به جدیدترین اول"}
|
||||
>
|
||||
<span aria-hidden className="text-accent">⇅</span>
|
||||
<span>{sortDir === "desc" ? "جدیدترین اول" : "قدیمیترین اول"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useLayoutEffect, useState } from "react";
|
||||
|
||||
export interface FlowRefs {
|
||||
items: Map<string, HTMLDivElement>;
|
||||
termsheets: Map<string, HTMLDivElement>;
|
||||
}
|
||||
|
||||
export interface FlowDiagramProps {
|
||||
flowEl: HTMLDivElement | null;
|
||||
refs: FlowRefs;
|
||||
redrawKey?: string; // وقتی دادهی quote عوض شد (پذیرش/رسید/ویرایش) خطوط دوباره رسم شوند
|
||||
}
|
||||
|
||||
interface PathSpec {
|
||||
d: string;
|
||||
accepted: boolean;
|
||||
}
|
||||
|
||||
// خطوط اتصال SVG بین جعبههای اقلام و ترمشیتها — موقعیت جعبهها با
|
||||
// getBoundingClientRect خوانده میشود و با ResizeObserver در جریان میماند.
|
||||
// خاکستری = باز، آبی = جفت پذیرفتهشدهی دقیق.
|
||||
export function FlowDiagram({ flowEl, refs, redrawKey = "" }: FlowDiagramProps) {
|
||||
const [paths, setPaths] = useState<PathSpec[]>([]);
|
||||
|
||||
const draw = useCallback(() => {
|
||||
if (!flowEl) return;
|
||||
const crect = flowEl.getBoundingClientRect();
|
||||
if (!crect.width) return;
|
||||
|
||||
const out: PathSpec[] = [];
|
||||
refs.items.forEach((item) => {
|
||||
const ir = item.getBoundingClientRect();
|
||||
const x1 = ir.left - crect.left; // لبهی چپ جعبهی قلم (رو به فاصله)
|
||||
const y1 = ir.top + ir.height / 2 - crect.top;
|
||||
const isRejected = item.classList.contains("rejected");
|
||||
const acceptedTsId = item.dataset.acceptedTs || "";
|
||||
const tsIds = (item.dataset.tsIds || "").split(",").filter(Boolean);
|
||||
tsIds.forEach((tsId) => {
|
||||
const t = refs.termsheets.get(tsId);
|
||||
if (!t) return;
|
||||
const tr = t.getBoundingClientRect();
|
||||
const x2 = tr.right - crect.left; // لبهی راست جعبهی termsheet
|
||||
const y2 = tr.top + tr.height / 2 - crect.top;
|
||||
const cx = (x1 + x2) / 2;
|
||||
// آبی فقط برای جفت دقیقی که قلم پذیرفته — بقیه خاکستری
|
||||
const accepted = !isRejected && acceptedTsId === tsId;
|
||||
out.push({
|
||||
d: `M ${x1} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}`,
|
||||
accepted,
|
||||
});
|
||||
});
|
||||
});
|
||||
setPaths(out);
|
||||
}, [flowEl, refs, redrawKey]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
draw();
|
||||
if (!flowEl) return;
|
||||
const ro = new ResizeObserver(draw);
|
||||
ro.observe(flowEl);
|
||||
window.addEventListener("resize", draw);
|
||||
if (typeof document !== "undefined" && document.fonts && document.fonts.ready) {
|
||||
void document.fonts.ready.then(draw);
|
||||
}
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", draw);
|
||||
};
|
||||
}, [draw, flowEl]);
|
||||
|
||||
return (
|
||||
<svg className="flow-lines" aria-hidden="true">
|
||||
{paths.map((p, i) => (
|
||||
<path key={i} className={`flow-line${p.accepted ? " accepted" : ""}`} d={p.d} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Inbox } from "lucide-react";
|
||||
import type { Quote, Store } from "../../lib/types";
|
||||
import { QuoteCard } from "./QuoteCard";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
|
||||
export interface LedgerTableProps {
|
||||
store: Store;
|
||||
quotes: Quote[];
|
||||
expanded: Set<string>;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onAccept: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
}
|
||||
|
||||
// دفتر پیشنهادها — فهرست کارتها (هر پیشنهاد یک کارت خوانا)
|
||||
export function LedgerTable({
|
||||
store,
|
||||
quotes,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onAccept,
|
||||
onEdit,
|
||||
}: LedgerTableProps) {
|
||||
if (!quotes.length) {
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel">
|
||||
<EmptyState icon={Inbox} text="پیشنهادی مطابق فیلترها یافت نشد" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{quotes.map((q) => (
|
||||
<QuoteCard
|
||||
key={q.id}
|
||||
store={store}
|
||||
q={q}
|
||||
expanded={expanded.has(q.id)}
|
||||
onToggleExpand={() => onToggleExpand(q.id)}
|
||||
onAccept={() => onAccept(q.id)}
|
||||
onEdit={() => onEdit(q.id)}
|
||||
/>
|
||||
))}
|
||||
<div className="py-1 text-center text-[11px] text-muted">
|
||||
هشدار «منقضی» = قیمت پیشنهادی از تاریخ انقضا گذشته؛ برای پیشبینی قیمت، شمارش نمیشود.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard } from "lucide-react";
|
||||
import type { Quote, Store } from "../../lib/types";
|
||||
import {
|
||||
isExpired,
|
||||
itemsSummary,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
projectCell,
|
||||
quoteTotal,
|
||||
supplierById,
|
||||
termsheetSummary,
|
||||
unitWarning,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { STATUS_LABEL } from "../../lib/constants";
|
||||
import { esc, fmtDate, fmtDateTime, fmtNum, priceCell } from "../../lib/formatters";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { QuoteDetail } from "./QuoteDetail";
|
||||
|
||||
export interface QuoteCardProps {
|
||||
store: Store;
|
||||
q: Quote;
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onAccept: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
function InfoCell({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="mb-0.5 text-[10.5px] font-semibold text-muted">{label}</div>
|
||||
<div className="min-w-0 text-[13px] leading-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// کارت یک پیشنهاد در دفتر — جایگزین جدول ۱۱ ستونه
|
||||
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
|
||||
const sup = supplierById(store.suppliers, q.supplier_id);
|
||||
const expired = isExpired(q);
|
||||
const total = quoteTotal(q);
|
||||
const pc = priceCell(total);
|
||||
const ts = termsheetSummary(store, q);
|
||||
const unitWarns = unitWarning(store, q);
|
||||
const summary = itemsSummary(store, q);
|
||||
|
||||
// ستون پذیرش (با ساعت)
|
||||
let acceptedCell: React.ReactNode;
|
||||
if (q.status === "offered") {
|
||||
acceptedCell = (
|
||||
<button className="chip chip-act" onClick={onAccept}>
|
||||
پذیرش
|
||||
</button>
|
||||
);
|
||||
} else if (q.accepted_date) {
|
||||
acceptedCell = <span className="tabular-nums">{fmtDateTime(q.accepted_date)}</span>;
|
||||
} else {
|
||||
acceptedCell = <span className="text-muted">—</span>;
|
||||
}
|
||||
|
||||
// ستون رسید — v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام میشود؛
|
||||
// اینجا فقط وضعیت نمایش داده میشود.
|
||||
let arrivedCell: React.ReactNode;
|
||||
if (q.status === "accepted") {
|
||||
if (q.arrived_date) {
|
||||
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
||||
} else {
|
||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
||||
const arrivedCount = items.filter((li) => li.arrived_date).length;
|
||||
arrivedCell = arrivedCount ? (
|
||||
<span className="tabular-nums">{fmtNum(arrivedCount)}/{fmtNum(items.length)}</span>
|
||||
) : (
|
||||
<span className="text-muted">در انتظار PM</span>
|
||||
);
|
||||
}
|
||||
} else if (q.arrived_date) {
|
||||
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
||||
} else {
|
||||
arrivedCell = <span className="text-muted">—</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border bg-panel transition-colors",
|
||||
expired ? "border-amber/40" : "border-line hover:border-accent/40",
|
||||
expanded && "border-accent/60",
|
||||
)}
|
||||
>
|
||||
{/* سربرگ کارت */}
|
||||
<div className="flex items-center gap-3 border-b border-line bg-bg/40 px-4 py-2.5">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-sm font-bold text-accent-dark">
|
||||
{sup.name.slice(0, 1)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/suppliers/${q.supplier_id}`}
|
||||
className="block truncate text-[13.5px] font-bold text-ink no-underline hover:text-accent"
|
||||
>
|
||||
{sup.name}
|
||||
</Link>
|
||||
{sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>}
|
||||
</div>
|
||||
<div className="ms-auto flex shrink-0 items-center gap-1.5">
|
||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
|
||||
<button className="chip" onClick={onEdit} title="ویرایش پیشنهاد">
|
||||
<Pencil className="h-3 w-3" />
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
className="chip chip-expand"
|
||||
onClick={onToggleExpand}
|
||||
title={expanded ? "جمع کردن" : "نمایش جریان"}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* اطلاعات کلیدی */}
|
||||
<div className="grid grid-cols-2 gap-x-5 gap-y-3 px-4 py-3 md:grid-cols-4">
|
||||
<InfoCell label="اقلام">
|
||||
{summary.count > 1 ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold">{fmtNum(summary.count)} قلم</span>
|
||||
<div className="flex flex-col gap-1 text-[11.5px] leading-5 text-muted">
|
||||
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const specs = materialSpecs(m);
|
||||
return (
|
||||
<span key={li.id} className="flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
|
||||
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""}
|
||||
<span className="text-[10px] text-muted/70">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
summary.text
|
||||
)}
|
||||
{unitWarns.map((w) => (
|
||||
<Link
|
||||
key={w.unit}
|
||||
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
||||
className="unit-warn mt-1"
|
||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||
>
|
||||
<TriangleAlert className="h-2.5 w-2.5" />
|
||||
واحد جدید: {w.unit}
|
||||
</Link>
|
||||
))}
|
||||
</InfoCell>
|
||||
|
||||
<InfoCell label="پروژه">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
{q.projects && q.projects.length > 0 ? (
|
||||
<span className="flex flex-col gap-0.5">
|
||||
{q.projects.map((p, i) => (
|
||||
<span key={i}>{p}</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
projectCell(q)
|
||||
)}
|
||||
</span>
|
||||
</InfoCell>
|
||||
|
||||
<InfoCell label="مجموع">
|
||||
{pc ? (
|
||||
<span className="font-bold tabular-nums">
|
||||
{pc.num} <span className="num-words">تومان</span> <span className="num-words">({pc.words})</span>
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</InfoCell>
|
||||
|
||||
<InfoCell label="معامله">
|
||||
{ts.title !== "—" ? (
|
||||
<div>
|
||||
<div className="font-semibold" style={ts.color ? { color: ts.color } : undefined}>
|
||||
{ts.title}
|
||||
</div>
|
||||
{ts.sub && <div className="text-[11px] text-muted">{ts.sub}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</InfoCell>
|
||||
</div>
|
||||
|
||||
{/* تاریخها و اقدامات */}
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-line bg-bg/40 px-4 py-2 text-[11.5px] text-muted">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Package className="h-3 w-3" />
|
||||
پیشنهاد: <b className="text-ink tabular-nums">{fmtDateTime(q.quote_date)}</b>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CreditCard className="h-3 w-3" />
|
||||
معتبر تا:{" "}
|
||||
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
|
||||
{fmtDate(q.expiry_date)}
|
||||
</b>
|
||||
{expired && <AlarmClock className="h-3 w-3 text-amber" aria-label="منقضی" />}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
پذیرش: {acceptedCell}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
رسید: {arrivedCell}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* جریان (expand) */}
|
||||
{expanded && <QuoteDetail store={store} q={q} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
|
||||
import type { Attachment, Quote, Store } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import {
|
||||
itemTermsheetIds,
|
||||
lineItemTotal,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
refConverted,
|
||||
specsLabel,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { fmtDate, fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
|
||||
import { itemProjects } from "../../lib/quoteUtils";
|
||||
|
||||
export interface QuoteDetailProps {
|
||||
store: Store;
|
||||
q: Quote;
|
||||
}
|
||||
|
||||
// بخش expanded یک پیشنهاد: اقلام (راست) ↔ ترمشیتها (چپ) با خطوط SVG رنگی
|
||||
export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
const [flowEl, setFlowEl] = useState<HTMLDivElement | null>(null);
|
||||
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
|
||||
const [bump, setBump] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// بعد از نشستن layout (فونت/تصویر) خطوط دوباره رسم شوند
|
||||
const t = setTimeout(() => setBump((b) => b + 1), 350);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
void bump;
|
||||
|
||||
// پیوستها (فاکتور/پیشفاکتور) مستقیماً از سرور (MinIO) خوانده میشوند
|
||||
const [attachments, setAttachments] = useState<{ invoices: Attachment[]; proformas: Attachment[] }>({
|
||||
invoices: [],
|
||||
proformas: [],
|
||||
});
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void Promise.all([
|
||||
api.files.list(q.id, "invoice").catch(() => [] as Attachment[]),
|
||||
api.files.list(q.id, "proforma").catch(() => [] as Attachment[]),
|
||||
]).then(([invoices, proformas]) => {
|
||||
if (alive) setAttachments({ invoices, proformas });
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [q.id]);
|
||||
|
||||
const tsList = q.termsheets || [];
|
||||
const allItems = q.line_items || [];
|
||||
const items = allItems.filter((li) => !li.rejected);
|
||||
const rejectedItems = allItems.filter((li) => li.rejected);
|
||||
|
||||
// وقتی دادهی جریان عوض شد (پذیرش/رسید/ویرایش) خطوط دوباره رسم شوند
|
||||
const redrawKey = JSON.stringify({
|
||||
items: allItems.map((li) => [li.id, li.accepted_ts_id, !!li.rejected, !!li.arrived_date]),
|
||||
ts: tsList.map((t) => [t.id, t.status]),
|
||||
});
|
||||
|
||||
const itemBox = (li: (typeof allItems)[number], isRejected: boolean) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid));
|
||||
const total = lineItemTotal(li);
|
||||
const projs = itemProjects(q, li);
|
||||
const conv = isRejected ? null : refConverted(store, li);
|
||||
const specText = specsLabel(materialSpecs(m));
|
||||
return (
|
||||
<div
|
||||
key={li.id}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
flowRefs.items.set(li.id, el);
|
||||
el.dataset.tsIds = tsIds.join(",");
|
||||
el.dataset.acceptedTs = li.accepted_ts_id || "";
|
||||
} else {
|
||||
flowRefs.items.delete(li.id);
|
||||
}
|
||||
}}
|
||||
className={`flow-box flow-item${isRejected ? " rejected" : ""}${li.arrived_date ? " deal" : ""}`}
|
||||
>
|
||||
<div className="fb-name">
|
||||
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
|
||||
{m.category} {m.brand}
|
||||
{specText ? ` · ${specText}` : ""}
|
||||
</div>
|
||||
<div className="fb-meta">
|
||||
<Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"}
|
||||
</div>
|
||||
<div className="fb-nums">
|
||||
<span>
|
||||
{fmtNum(li.quantity)} {li.unit || ""}
|
||||
</span>
|
||||
<span>× {groupCommas(li.unit_price, true)}</span>
|
||||
<span className="fb-total">= {total != null ? groupCommas(total, true) + " تومان" : "—"}</span>
|
||||
</div>
|
||||
{conv && (
|
||||
<div className="fb-conv">
|
||||
<Scale className="me-1 inline h-3 w-3" /> ≈ {groupCommas(conv.qty, true)} {conv.unit}
|
||||
{conv.perRef != null ? ` · ${groupCommas(conv.perRef, true)} تومان/${conv.unit}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const attachParts: React.ReactNode[] = [];
|
||||
attachments.proformas.forEach((pf, i) => {
|
||||
if (!pf.id) return;
|
||||
attachParts.push(
|
||||
<a key={"pf" + i} className="detail-link" href={api.files.downloadUrl(q.id, "proforma", pf.id)} download={pf.name}>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
پیشفاکتور: {pf.name}
|
||||
</a>,
|
||||
);
|
||||
});
|
||||
attachments.invoices.forEach((inv, i) => {
|
||||
if (!inv.id) return;
|
||||
attachParts.push(
|
||||
<a key={"inv" + i} className="detail-link" href={api.files.downloadUrl(q.id, "invoice", inv.id)} download={inv.name}>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
فاکتور اصلی: {inv.name}
|
||||
</a>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setFlowEl} className="flow border-t border-line" data-status={q.status}>
|
||||
<div className="flow-cols">
|
||||
<div className="flow-col flow-items-col">
|
||||
<div className="flow-col-h">
|
||||
<Package className="h-3.5 w-3.5" /> اقلام ({fmtNum(items.length)})
|
||||
</div>
|
||||
{items.length ? items.map((li) => itemBox(li, false)) : <div className="flow-note">—</div>}
|
||||
{rejectedItems.length > 0 && (
|
||||
<>
|
||||
<div className="flow-rejected-h">
|
||||
<Ban className="h-3 w-3" /> قلمهای رد شده (از معامله خارجاند)
|
||||
</div>
|
||||
{rejectedItems.map((li) => itemBox(li, true))}
|
||||
<div className="flow-rejected-hint">
|
||||
با بازگشت وضعیت به مرحلهی پیشین (یا ویرایش)، این قلمها برمیگردند.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flow-col flow-terms-col">
|
||||
<div className="flow-col-h">
|
||||
<CreditCard className="h-3.5 w-3.5" /> شرایط پرداخت ({fmtNum(tsList.length)})
|
||||
</div>
|
||||
{tsList.length ? (
|
||||
tsList.map((t) => {
|
||||
const isAccepted = t.status === "accepted";
|
||||
const arrived = q.status === "arrived" && isAccepted;
|
||||
const badge = arrived ? (
|
||||
<span className="fb-badge arrived">رسیده</span>
|
||||
) : isAccepted ? (
|
||||
<span className="fb-badge accepted">✓ پذیرفته</span>
|
||||
) : (
|
||||
<span className="fb-badge offered">پیشنهاد</span>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
ref={(el) => {
|
||||
if (el) flowRefs.termsheets.set(t.id, el);
|
||||
else flowRefs.termsheets.delete(t.id);
|
||||
}}
|
||||
className={`flow-box flow-ts${isAccepted ? (arrived ? " ts-arrived" : " ts-accepted") : ""}`}
|
||||
>
|
||||
<div className="fb-name">
|
||||
<CreditCard className="h-3.5 w-3.5 text-muted" />
|
||||
{t.label} {badge}
|
||||
</div>
|
||||
<div className="fb-ms-list">
|
||||
{(t.milestones || []).length ? (
|
||||
t.milestones.map((m, i) => (
|
||||
<div key={i} className="fb-ms">
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
{m.when_iso ? fmtDate(m.when_iso) : "—"} ·{" "}
|
||||
{m.amount != null ? groupCommas(m.amount, true) + " تومان" : "—"}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="fb-ms">—</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flow-note">—</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FlowDiagram flowEl={flowEl} refs={flowRefs} redrawKey={redrawKey} />
|
||||
{attachParts.length > 0 && (
|
||||
<div className="flow-attach">
|
||||
{attachParts.map((p, i) => (
|
||||
<span key={i} className="detail-item">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { AlarmClock, ChevronDown, TriangleAlert } from "lucide-react";
|
||||
import type { Quote, Store } from "../../lib/types";
|
||||
import {
|
||||
isExpired,
|
||||
itemsSummary,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
projectCell,
|
||||
quoteTotal,
|
||||
supplierById,
|
||||
termsheetSummary,
|
||||
unitWarning,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { STATUS_LABEL } from "../../lib/constants";
|
||||
import { esc, fmtDate, fmtNum, priceCell } from "../../lib/formatters";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface QuoteRowProps {
|
||||
store: Store;
|
||||
q: Quote;
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onAccept: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
// یک ردیف جمعشدهی پیشنهاد در دفتر
|
||||
export function QuoteRow({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteRowProps) {
|
||||
const sup = supplierById(store.suppliers, q.supplier_id);
|
||||
const expired = isExpired(q);
|
||||
const total = quoteTotal(q);
|
||||
const pc = priceCell(total);
|
||||
|
||||
const ts = termsheetSummary(store, q);
|
||||
const unitWarns = unitWarning(store, q);
|
||||
|
||||
// ستون پذیرش
|
||||
let acceptedCell: React.ReactNode;
|
||||
if (q.status === "offered") {
|
||||
acceptedCell = (
|
||||
<button className="chip chip-act" onClick={onAccept}>
|
||||
پذیرش
|
||||
</button>
|
||||
);
|
||||
} else if (q.accepted_date) {
|
||||
acceptedCell = <span className="text-xs tabular-nums">{fmtDate(q.accepted_date)}</span>;
|
||||
} else {
|
||||
acceptedCell = <span className="text-xs text-muted">—</span>;
|
||||
}
|
||||
|
||||
// ستون رسید
|
||||
let arrivedCell: React.ReactNode;
|
||||
if (q.status === "accepted") {
|
||||
if (q.arrived_date) {
|
||||
arrivedCell = <span className="text-xs tabular-nums">{fmtDate(q.arrived_date)}</span>;
|
||||
} else {
|
||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
||||
const arrivedCount = items.filter((li) => li.arrived_date).length;
|
||||
// v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام میشود.
|
||||
arrivedCell = arrivedCount ? (
|
||||
<span className="text-xs tabular-nums">{fmtNum(arrivedCount)}/{fmtNum(items.length)}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted">در انتظار PM</span>
|
||||
);
|
||||
}
|
||||
} else if (q.arrived_date) {
|
||||
arrivedCell = <span className="text-xs tabular-nums">{fmtDate(q.arrived_date)}</span>;
|
||||
} else {
|
||||
arrivedCell = <span className="text-xs text-muted">—</span>;
|
||||
}
|
||||
|
||||
const summary = itemsSummary(store, q);
|
||||
|
||||
return (
|
||||
<tr className={cn("quote-row transition-colors", expired && "stale-row", expanded && "bg-accent-bg/40")}>
|
||||
{/* تأمینکننده */}
|
||||
<td>
|
||||
<Link to={`/suppliers/${q.supplier_id}`} className="text-[13px] font-semibold text-accent no-underline hover:underline">
|
||||
{sup.name}
|
||||
</Link>
|
||||
{sup.location && <div className="text-[11px] text-muted">{sup.location}</div>}
|
||||
</td>
|
||||
|
||||
{/* اقلام */}
|
||||
<td>
|
||||
<div className="text-[13px] leading-5">
|
||||
{summary.count > 1 ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<b>{fmtNum(summary.count)} قلم</b>
|
||||
<div className="flex flex-col gap-0.5 text-[11px] text-muted">
|
||||
{(q.line_items || []).filter((li) => !li.rejected).map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const specs = materialSpecs(m);
|
||||
return (
|
||||
<span key={li.id} className="flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
|
||||
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""}
|
||||
<span className="text-[10px] text-muted/70">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
summary.text
|
||||
)}
|
||||
</div>
|
||||
{unitWarns.map((w) => (
|
||||
<Link
|
||||
key={w.unit}
|
||||
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
||||
className="unit-warn"
|
||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||
>
|
||||
<TriangleAlert className="h-2.5 w-2.5" />
|
||||
واحد جدید: {w.unit}
|
||||
</Link>
|
||||
))}
|
||||
</td>
|
||||
|
||||
{/* پروژه */}
|
||||
<td className="text-[13px]">{projectCell(q)}</td>
|
||||
|
||||
{/* مجموع */}
|
||||
<td className="text-[13px] font-semibold tabular-nums whitespace-nowrap">
|
||||
{pc ? (
|
||||
<>
|
||||
{pc.num} <span className="num-words">({pc.words})</span>
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* معامله */}
|
||||
<td>
|
||||
{ts.title !== "—" ? (
|
||||
<div>
|
||||
<div className="text-[12px] font-semibold leading-5" style={ts.color ? { color: ts.color } : undefined}>
|
||||
{ts.title}
|
||||
</div>
|
||||
{ts.sub && <div className="text-[11px] text-muted">{ts.sub}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* تاریخها */}
|
||||
<td className="text-xs tabular-nums whitespace-nowrap">{fmtDate(q.quote_date)}</td>
|
||||
<td className="text-xs tabular-nums whitespace-nowrap">
|
||||
{fmtDate(q.expiry_date)}
|
||||
{expired && <AlarmClock className="ms-1 inline h-3 w-3 text-amber" aria-label="منقضی" />}
|
||||
</td>
|
||||
<td>{acceptedCell}</td>
|
||||
<td>{arrivedCell}</td>
|
||||
|
||||
{/* وضعیت */}
|
||||
<td>
|
||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
|
||||
</td>
|
||||
|
||||
{/* عملیات */}
|
||||
<td>
|
||||
<div className="flex items-center gap-1">
|
||||
<button className="chip" onClick={onEdit}>
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
className="chip chip-expand"
|
||||
onClick={onToggleExpand}
|
||||
title={expanded ? "جمع کردن" : "نمایش جریان"}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180")} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import type { PendingArrival, Warehouse } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
|
||||
// رسید کالاهای در راه — مدیر پروژه تأیید و بین انبارها تقسیم میکند (v2.6)
|
||||
export function ArrivalsPage() {
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [items, setItems] = useState<PendingArrival[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
const [selected, setSelected] = useState<Record<string, string[]>>({}); // decisionId -> warehouseIds
|
||||
const [qty, setQty] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== "pm") return;
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const [arr, whs] = await Promise.all([
|
||||
apiV26.pm.onTheWay(),
|
||||
apiV26.warehouses.list(user.project_id || undefined),
|
||||
]);
|
||||
if (!alive) return;
|
||||
setItems(arr);
|
||||
setWarehouses(whs);
|
||||
} catch (err) {
|
||||
if (alive) toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [user?.role, user?.project_id]);
|
||||
|
||||
const pending = useMemo(() => items.filter((i) => i.state !== "arrived"), [items]);
|
||||
|
||||
const toggleWarehouse = (decisionId: string, whId: string) => {
|
||||
setSelected((s) => {
|
||||
const cur = s[decisionId] || [];
|
||||
return { ...s, [decisionId]: cur.includes(whId) ? cur.filter((x) => x !== whId) : [...cur, whId] };
|
||||
});
|
||||
};
|
||||
|
||||
const confirm = async (it: PendingArrival) => {
|
||||
const qtyStr = qty[it.item_decision_id]?.trim();
|
||||
const total = parseFloat(qtyStr || "");
|
||||
if (!total || total <= 0) {
|
||||
toast.error("تعداد رسیده را وارد کنید.");
|
||||
return;
|
||||
}
|
||||
const allocIds = selected[it.item_decision_id] || [];
|
||||
if (!allocIds.length) {
|
||||
toast.error("حداقل یک انبار را برای تقسیم انتخاب کنید.");
|
||||
return;
|
||||
}
|
||||
// تقسیم مساوی بین انبارهای انتخابشده (بهصورت پیشفرض؛ کاربر میتواند بعداً اصلاح کند)
|
||||
const per = Math.round((total / allocIds.length) * 10000) / 10000;
|
||||
const allocations = allocIds.map((whId, idx) => ({
|
||||
warehouse_id: whId,
|
||||
quantity: idx === allocIds.length - 1 ? +(total - per * (allocIds.length - 1)).toFixed(4) : per,
|
||||
}));
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiV26.pm.confirmReceipt({
|
||||
item_decision_id: it.item_decision_id,
|
||||
received_qty: total,
|
||||
allocations,
|
||||
});
|
||||
toast.success(`رسید ثبت شد — ${res.posted_transactions.length} تراکنش خرید ثبت شد.`);
|
||||
setItems((list) => list.filter((x) => x.item_decision_id !== it.item_decision_id));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ثبت رسید");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="min-h-screen" />;
|
||||
if (user?.role !== "pm") {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="رسید کالاها" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<PageLayout>
|
||||
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه است." />
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="کالاهای در راه و رسید"
|
||||
sub={user.project_name || ""}
|
||||
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
{!pending.length ? (
|
||||
<EmptyState emoji="🚚" text="کالای در راهی برای تأیید رسید وجود ندارد." />
|
||||
) : (
|
||||
pending.map((it) => (
|
||||
<div key={it.item_decision_id} className="rounded-xl border border-line bg-panel p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[14px] font-bold">{it.material.name}</span>
|
||||
<Badge variant="outline">
|
||||
{it.state === "on_the_way" ? "در راه" : "رسید ناقص"}
|
||||
</Badge>
|
||||
<span className="text-[12px] text-muted">
|
||||
سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""} — رسیده: {faNum(parseFloat(it.received_qty))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<div className="field">
|
||||
<label>تعداد رسیده (در واحد قلم)</label>
|
||||
<input
|
||||
type="number"
|
||||
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"
|
||||
value={qty[it.item_decision_id] || ""}
|
||||
onChange={(e) => setQty((q) => ({ ...q, [it.item_decision_id]: e.target.value }))}
|
||||
placeholder={it.ordered_qty}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تقسیم بین انبارها (چندتایی)</label>
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{warehouses.map((w) => {
|
||||
const on = (selected[it.item_decision_id] || []).includes(w.id);
|
||||
return (
|
||||
<button
|
||||
key={w.id}
|
||||
type="button"
|
||||
className={`rounded-lg border px-3 py-1.5 text-[12.5px] ${on ? "border-accent bg-accent/10 text-accent" : "border-line bg-panel text-muted"}`}
|
||||
onClick={() => toggleWarehouse(it.item_decision_id, w.id)}
|
||||
>
|
||||
{w.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button size="sm" variant="green" disabled={saving} onClick={() => void confirm(it)}>
|
||||
ثبت رسید و ورود به انبار
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import { useInventoryData, unitName } from "./useInventoryData";
|
||||
import type { InventoryTransaction, TransactionType } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { gregorianToJalali, toFa } from "../../lib/jalali";
|
||||
|
||||
type FormType = TransactionType | "TRANSFER";
|
||||
|
||||
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
|
||||
{ t: "PURCHASE", label: "خرید", dir: "افزایش", cls: "in", color: "#16a34a", desc: "ثبت خرید کالای جدید یا موجود — دستهبندی فقط برای کالای جدید لازم است. ثبت حداقل یک مشخصه فنی (نام و مقدار) الزامی است." },
|
||||
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدلها (برند و مشخصه متفاوت) نمایش داده میشوند. برداشتها در «کنترل روزانه» امروز دیده میشوند." },
|
||||
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#16a34a", desc: "برگشت از مصالحی که امروز برداشت شدهاند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
|
||||
{ t: "TRANSFER", label: "انتقال", dir: "جابهجایی", cls: "mv", color: "#4f46e5", desc: "جابهجایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
|
||||
{ t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "ثبت ضایعات خرید (مثلاً لولههای سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." },
|
||||
{ t: "WASTE", label: "اتلاف", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "اتلاف بعد از خرید (خرابی در انبار یا هدررفت در کار) — انبار و متریال را انتخاب کنید." },
|
||||
{ t: "LOSS", label: "مفقودی", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "ثبت مفقودی — انبار و متریال را انتخاب و تعداد مفقودی هر مدل را وارد کنید." },
|
||||
{ t: "FOUND", label: "پیدا شده", dir: "افزایش", cls: "in", color: "#16a34a", desc: "ثبت پیدا شدن کالاهای مفقود — مفقودیهای همان انبار و متریال (یا «همه مفقودیها») نمایش داده میشود." },
|
||||
{ t: "STOCK_COUNT", label: "شمارش موجودی", dir: "اصلاح", cls: "cnt", color: "#64748b", desc: "شمارش فیزیکی — مقدار شمارششده هر رکورد را وارد کنید تا موجودی اصلاح شود و اختلاف ثبت گردد." },
|
||||
];
|
||||
|
||||
interface RowDraft { key: string; qty: string; note: string; }
|
||||
|
||||
function todayKey(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function gregKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function jalDateLabel(d: Date): string {
|
||||
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
return `${toFa(jd)} ${["فروردین","اردیبهشت","خرداد","تیر","مرداد","شهریور","مهر","آبان","آذر","دی","بهمن","اسفند"][jm-1]} ${toFa(jy)}`;
|
||||
}
|
||||
|
||||
// تغییر موجودی — پورت کامل صفحهی ارسالی (همه انواع تراکنش)
|
||||
export function ChangeInventoryPage() {
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const data = useInventoryData();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const [type, setType] = useState<FormType>("PURCHASE");
|
||||
const [whId, setWhId] = useState("");
|
||||
const [fromId, setFromId] = useState("");
|
||||
const [toId, setToId] = useState("");
|
||||
const [matTxt, setMatTxt] = useState("");
|
||||
const [catId, setCatId] = useState("");
|
||||
const [brandTxt, setBrandTxt] = useState("");
|
||||
const [unitTxt, setUnitTxt] = useState("");
|
||||
const [qty, setQty] = useState("");
|
||||
const [dateInput, setDateInput] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||
return d.toISOString().slice(0, 16);
|
||||
});
|
||||
const [specsRows, setSpecsRows] = useState<{ rid: number; label: string; value: string }[]>([
|
||||
{ rid: 1, label: "", value: "" },
|
||||
]);
|
||||
const [note, setNote] = useState("");
|
||||
const [rows, setRows] = useState<RowDraft[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// prefill از URL (پرش از کنترل روزانه)
|
||||
useEffect(() => {
|
||||
const t = params.get("type");
|
||||
const w = params.get("wh");
|
||||
const m = params.get("mat");
|
||||
if (t && FORM_TYPES.some((f) => f.t === t)) {
|
||||
setType(t as TransactionType);
|
||||
if (w) setWhId(w);
|
||||
if (m) setMatTxt(m);
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
const isAdmin = user?.role === "admin";
|
||||
const isPM = user?.role === "pm";
|
||||
|
||||
const warehouses = data.warehouses;
|
||||
const whOpts = useMemo(
|
||||
() => (isAdmin ? warehouses : warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""))),
|
||||
[warehouses, isAdmin, user?.project_id],
|
||||
);
|
||||
|
||||
const formRecords = useMemo(() => {
|
||||
const txt = matTxt.trim();
|
||||
return data.inventory.filter((r) => {
|
||||
if (r.warehouse?.id !== whId) return false;
|
||||
if (txt && !r.material.name.includes(txt)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [data.inventory, whId, matTxt]);
|
||||
|
||||
const matByName = (name: string) => data.lookup.materials.find((m) => m.name === name) || null;
|
||||
const brandIdByName = (name: string) => data.lookup.brands.find((b) => b.name === name)?.id || null;
|
||||
const unitIdByName = (name: string) => data.lookup.units.find((u) => (u.name_fa || u.code) === name)?.id || null;
|
||||
const parseNum = (s: string) => { const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); return isFinite(v) ? v : NaN; };
|
||||
|
||||
const specsFromRows = () => {
|
||||
const out: Record<string, string> = {};
|
||||
specsRows.forEach((r) => {
|
||||
if (r.label.trim() && r.value.trim()) out[r.label.trim()] = r.value.trim();
|
||||
});
|
||||
return out;
|
||||
};
|
||||
const specsComplete = specsRows.some((r) => r.label.trim() && r.value.trim());
|
||||
|
||||
const todayWithdrawals = useMemo(() => {
|
||||
const tk = todayKey();
|
||||
const txt = matTxt.trim();
|
||||
return data.transactions
|
||||
.filter((t) => {
|
||||
if (t.transaction_type !== "WITHDRAWAL" || t.warehouse_id !== whId) return false;
|
||||
if (gregKey(new Date(t.transaction_date)) !== tk) return false;
|
||||
if (txt && !(t.material?.name || "").includes(txt)) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
}, [data.transactions, whId, matTxt]);
|
||||
|
||||
const lossTxs = useMemo(() => {
|
||||
const txt = matTxt.trim();
|
||||
return data.transactions
|
||||
.filter((t) => {
|
||||
if (t.transaction_type !== "LOSS" || t.warehouse_id !== whId) return false;
|
||||
if (txt && !(t.material?.name || "").includes(txt)) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
}, [data.transactions, whId, matTxt]);
|
||||
|
||||
const postOne = async (op: Record<string, unknown>) => {
|
||||
await apiV26.inventory.createTransaction(op as never);
|
||||
};
|
||||
|
||||
const postSeq = async (ops: Record<string, unknown>[]) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
for (const op of ops) await postOne(op);
|
||||
toast.success("ثبت شد ✓");
|
||||
setRows([]);
|
||||
setMatTxt("");
|
||||
setBrandTxt("");
|
||||
setUnitTxt("");
|
||||
setQty("");
|
||||
setSpecsRows([{ rid: Date.now(), label: "", value: "" }]);
|
||||
setNote("");
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ثبت");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- submit handlers ----------
|
||||
const submitPurchase = async () => {
|
||||
const m = matByName(matTxt);
|
||||
const bid = brandIdByName(brandTxt);
|
||||
const uid = unitIdByName(unitTxt);
|
||||
const n = parseNum(qty);
|
||||
if (!whId) return toast.error("انبار را انتخاب کنید");
|
||||
if (!m) return toast.error("متریال در فهرست نیست — نام را دقیق تایپ کنید");
|
||||
if (!m.category && !catId) return toast.error("برای کالای جدید، دستهبندی را انتخاب کنید");
|
||||
if (!bid) return toast.error("برند را انتخاب کنید");
|
||||
if (!uid) return toast.error("واحد را انتخاب کنید");
|
||||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||||
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی (نام و مقدار) ثبت کنید");
|
||||
const op: Record<string, unknown> = {
|
||||
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
|
||||
quantity: n, unit_id: uid, specs: specsFromRows(), note,
|
||||
transaction_date: new Date(dateInput).toISOString(),
|
||||
};
|
||||
await postSeq([op]);
|
||||
};
|
||||
|
||||
const submitList = async (t: TransactionType) => {
|
||||
const isCount = t === "STOCK_COUNT";
|
||||
const ops: Record<string, unknown>[] = rows
|
||||
.map((r, i): Record<string, unknown> | null => {
|
||||
const n = parseNum(r.qty);
|
||||
if (!(n > 0)) return null;
|
||||
const rec = formRecords[i];
|
||||
if (!rec) return null;
|
||||
if (isCount) {
|
||||
return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: new Date(dateInput).toISOString() };
|
||||
}
|
||||
return { transaction_type: t, warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: new Date(dateInput).toISOString() };
|
||||
})
|
||||
.filter((x): x is Record<string, unknown> => x !== null);
|
||||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||||
await postSeq(ops);
|
||||
};
|
||||
|
||||
const submitReturn = async () => {
|
||||
const ops: Record<string, unknown>[] = rows
|
||||
.map((r, i): Record<string, unknown> | null => {
|
||||
const n = parseNum(r.qty);
|
||||
if (!(n > 0)) return null;
|
||||
const t = todayWithdrawals[i];
|
||||
if (!t) return null;
|
||||
return { transaction_type: "RETURN", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: new Date(dateInput).toISOString() };
|
||||
})
|
||||
.filter((x): x is Record<string, unknown> => x !== null);
|
||||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||||
await postSeq(ops);
|
||||
};
|
||||
|
||||
const submitTransfer = async () => {
|
||||
const ops: Record<string, unknown>[] = rows
|
||||
.map((r, i): Record<string, unknown> | null => {
|
||||
const n = parseNum(r.qty);
|
||||
if (!(n > 0)) return null;
|
||||
const rec = formRecords[i];
|
||||
if (!rec) return null;
|
||||
return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: new Date(dateInput).toISOString() };
|
||||
})
|
||||
.filter((x): x is Record<string, unknown> => x !== null);
|
||||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||||
await postSeq(ops);
|
||||
};
|
||||
|
||||
const submitFound = async () => {
|
||||
const ops: Record<string, unknown>[] = rows
|
||||
.map((r, i): Record<string, unknown> | null => {
|
||||
const n = parseNum(r.qty);
|
||||
if (!(n > 0)) return null;
|
||||
const t = lossTxs[i];
|
||||
if (!t) return null;
|
||||
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: new Date(dateInput).toISOString() };
|
||||
})
|
||||
.filter((x): x is Record<string, unknown> => x !== null);
|
||||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||||
await postSeq(ops);
|
||||
};
|
||||
|
||||
const doSubmit = () => {
|
||||
if (type === "PURCHASE") void submitPurchase();
|
||||
else if (type === "RETURN") void submitReturn();
|
||||
else if (type === "TRANSFER") void submitTransfer();
|
||||
else if (type === "FOUND") void submitFound();
|
||||
else void submitList(type);
|
||||
};
|
||||
|
||||
const canSubmit = (): boolean => {
|
||||
if (type === "PURCHASE") {
|
||||
return !!(whId && matByName(matTxt) && brandIdByName(brandTxt) && unitIdByName(unitTxt) && parseNum(qty) > 0 && specsComplete);
|
||||
}
|
||||
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||
if (type === "FOUND") return !!(whId && rows.some((r) => parseNum(r.qty) > 0));
|
||||
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||
};
|
||||
|
||||
// sync rows when the record list changes
|
||||
useEffect(() => {
|
||||
if (type === "RETURN") {
|
||||
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
|
||||
} else if (type === "FOUND") {
|
||||
setRows(lossTxs.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
|
||||
} else if (type === "TRANSFER") {
|
||||
setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" })));
|
||||
} else {
|
||||
setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" })));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type, whId, fromId, toId, matTxt, formRecords.length, todayWithdrawals.length, lossTxs.length]);
|
||||
|
||||
const meta = FORM_TYPES.find((f) => f.t === type)!;
|
||||
|
||||
if (isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="تغییر موجودی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<PageLayout>
|
||||
<div className="rounded-xl border border-line bg-panel p-8 text-center">
|
||||
<div className="text-3xl">🔒</div>
|
||||
<h3 className="mt-2 text-[15px] font-bold">دسترسی فقطخواندنی</h3>
|
||||
<p className="mt-1 text-[12.5px] text-muted">نقش ادمین اجازه ثبت تغییر موجودی ندارد. این بخش برای مدیران پروژه فعال است.</p>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!isPM) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="تغییر موجودی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<PageLayout>
|
||||
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isCount = type === "STOCK_COUNT";
|
||||
const isTransfer = type === "TRANSFER";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="تغییر موجودی"
|
||||
sub={user?.project_name || ""}
|
||||
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
{/* انتخاب نوع تراکنش */}
|
||||
<div className="rounded-xl border border-line bg-panel p-3">
|
||||
<h3 className="mb-2 text-[13px] font-bold">نوع تغییر موجودی</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{FORM_TYPES.map((f) => (
|
||||
<button
|
||||
key={f.t}
|
||||
type="button"
|
||||
onClick={() => { setType(f.t); setRows([]); setMatTxt(""); setWhId(""); setFromId(""); setToId(""); }}
|
||||
className={`rounded-lg border px-3 py-2 text-[12.5px] ${type === f.t ? "font-bold text-white" : "border-line bg-panel"}`}
|
||||
style={type === f.t ? { background: f.color, borderColor: f.color } : undefined}
|
||||
>
|
||||
{f.label} <span className="text-[10.5px] opacity-80">({f.dir})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[11.5px] leading-5 text-muted">{meta.desc}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
{/* انتخاب انبارها */}
|
||||
{!isTransfer ? (
|
||||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
||||
<div className="field">
|
||||
<label>انبار *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={whId}
|
||||
onChange={(e) => { setWhId(e.target.value); setMatTxt(""); setRows([]); }}
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{(type === "PURCHASE" || type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
|
||||
<div className="field">
|
||||
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودیها)" : "متریال *"}</label>
|
||||
<input
|
||||
list="dl-mat"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder={type === "RETURN" ? "نام متریال برداشتشده امروز…" : "نام متریال — از پیشنهادها یا جدید…"}
|
||||
value={matTxt}
|
||||
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
||||
/>
|
||||
<datalist id="dl-mat">
|
||||
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
||||
<div className="field">
|
||||
<label>از (انبار مبدأ) *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={fromId}
|
||||
onChange={(e) => { setFromId(e.target.value); setToId(""); setMatTxt(""); setRows([]); }}
|
||||
>
|
||||
<option value="">— انتخاب انبار مبدأ —</option>
|
||||
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>به (انبار مقصد) *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={toId}
|
||||
disabled={!fromId}
|
||||
onChange={(e) => { setToId(e.target.value); setMatTxt(""); setRows([]); }}
|
||||
>
|
||||
<option value="">— انتخاب انبار مقصد —</option>
|
||||
{whOpts.filter((w) => w.id !== fromId).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field md:col-span-2">
|
||||
<label>متریال *</label>
|
||||
<input
|
||||
list="dl-mat2"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="نام متریال را تایپ کنید — همه مدلها نمایش داده میشوند…"
|
||||
value={matTxt}
|
||||
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
||||
/>
|
||||
<datalist id="dl-mat2">
|
||||
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* فرم خرید */}
|
||||
{type === "PURCHASE" && (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="field">
|
||||
<label>برند *</label>
|
||||
<input
|
||||
list="dl-brand"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="نام برند — از پیشنهادها یا جدید…"
|
||||
value={brandTxt}
|
||||
onChange={(e) => setBrandTxt(e.target.value)}
|
||||
/>
|
||||
<datalist id="dl-brand">
|
||||
{data.lookup.brands.map((b) => <option key={b.id} value={b.name} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>واحد *</label>
|
||||
<input
|
||||
list="dl-unit"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="واحد اندازهگیری…"
|
||||
value={unitTxt}
|
||||
onChange={(e) => setUnitTxt(e.target.value)}
|
||||
/>
|
||||
<datalist id="dl-unit">
|
||||
{data.lookup.units.map((u) => <option key={u.id} value={u.name_fa || u.code} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تعداد *</label>
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm ltr"
|
||||
placeholder="مثلاً ۲۰۰"
|
||||
value={qty}
|
||||
onChange={(e) => setQty(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ و زمان</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm ltr"
|
||||
value={dateInput}
|
||||
onChange={(e) => setDateInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{!matByName(matTxt) && matTxt.trim() && (
|
||||
<div className="field md:col-span-2">
|
||||
<label>دستهبندی (کالای جدید) *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={catId}
|
||||
onChange={(e) => setCatId(e.target.value)}
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
{data.lookup.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<div className="mt-1 text-[11px] text-muted">این متریال در فهرست نیست؛ فقط برای کالای جدید دستهبندی لازم است.</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="field md:col-span-2">
|
||||
<label>مشخصههای فنی *</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{specsRows.map((r) => (
|
||||
<div key={r.rid} className="flex gap-2">
|
||||
<input
|
||||
className="h-8 w-1/2 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="نام مشخصه (مثلاً سایز)"
|
||||
value={r.label}
|
||||
onChange={(e) => setSpecsRows((rs) => rs.map((x) => (x.rid === r.rid ? { ...x, label: e.target.value } : x)))}
|
||||
/>
|
||||
<input
|
||||
className="h-8 w-1/2 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="مقدار (مثلاً ۱۶)"
|
||||
value={r.value}
|
||||
onChange={(e) => setSpecsRows((rs) => rs.map((x) => (x.rid === r.rid ? { ...x, value: e.target.value } : x)))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 px-2 text-muted hover:text-red"
|
||||
onClick={() => setSpecsRows((rs) => (rs.length > 1 ? rs.filter((x) => x.rid !== r.rid) : rs))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[12px] text-accent"
|
||||
onClick={() => setSpecsRows((rs) => [...rs, { rid: Date.now(), label: "", value: "" }])}
|
||||
>
|
||||
+ افزودن مشخصه فنی
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field md:col-span-2">
|
||||
<label>یادداشت</label>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm"
|
||||
placeholder="شماره فاکتور، تأمینکننده، توضیحات…"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ردیفهای عملیات لیستی */}
|
||||
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT") && (
|
||||
<RowTable
|
||||
isCount={isCount}
|
||||
rows={rows}
|
||||
setRows={setRows}
|
||||
records={formRecords}
|
||||
unitNameFn={(id) => unitName(data.lookup.units, id)}
|
||||
emptyMsg={
|
||||
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
|
||||
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدلهای آن (با برند و مشخصه متفاوت) نمایش داده شود."
|
||||
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا خرید ثبت کنید."
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* برگشت — برداشتهای امروز */}
|
||||
{type === "RETURN" && (
|
||||
<ReturnTable rows={rows} setRows={setRows} txs={todayWithdrawals} whId={whId} matTxt={matTxt} unitNameFn={(id) => unitName(data.lookup.units, id)} />
|
||||
)}
|
||||
|
||||
{/* انتقال */}
|
||||
{type === "TRANSFER" && (
|
||||
<RowTable
|
||||
isCount={false}
|
||||
rows={rows}
|
||||
setRows={setRows}
|
||||
records={formRecords}
|
||||
unitNameFn={(id) => unitName(data.lookup.units, id)}
|
||||
emptyMsg={
|
||||
!fromId || !toId ? "برای نمایش موجودیها، انبار مبدأ، مقصد و نام متریال را وارد کنید."
|
||||
: !matTxt.trim() ? "نام متریال را تایپ کنید…"
|
||||
: !formRecords.length ? "در انبار مبدأ برای این متریال موجودی نیست."
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* پیدا شده — مفقودیها */}
|
||||
{type === "FOUND" && (
|
||||
<FoundTable rows={rows} setRows={setRows} txs={lossTxs} whId={whId} unitNameFn={(id) => unitName(data.lookup.units, id)} />
|
||||
)}
|
||||
|
||||
{/* دکمه ثبت */}
|
||||
<div className="mt-4 flex items-center justify-between border-t border-line pt-3">
|
||||
<span className="text-[12px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit() || busy}
|
||||
onClick={doSubmit}
|
||||
className="rounded-lg px-5 py-2 text-[13px] font-bold text-white disabled:opacity-40"
|
||||
style={{ background: meta.color }}
|
||||
>
|
||||
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{isTransfer ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد همزمان ثبت میشود." : `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
||||
|
||||
isCount: boolean;
|
||||
rows: RowDraft[];
|
||||
setRows: (r: RowDraft[]) => void;
|
||||
records: { id: string; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record<string, string> }[];
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
emptyMsg: string;
|
||||
}) {
|
||||
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-right text-muted">
|
||||
<th className="py-2 pe-3">مشخصهها</th>
|
||||
<th className="py-2 pe-3">برند</th>
|
||||
<th className="py-2 pe-3">واحد</th>
|
||||
<th className="py-2 pe-3">موجودی فعلی</th>
|
||||
<th className="py-2 pe-3">{isCount ? "شمارششده" : "تعداد"}</th>
|
||||
<th className="py-2">یادداشت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r, i) => (
|
||||
<tr key={r.id} className="border-b border-line/50">
|
||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{r.brand?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{unitNameFn(r.unit?.id)}</td>
|
||||
<td className="py-1.5 pe-3 font-bold">{faNum(r.quantity_number)}</td>
|
||||
<td className="py-1.5 pe-3">
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||
placeholder={isCount ? "شمارششده" : "۰"}
|
||||
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<input
|
||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="توضیح (اختیاری)"
|
||||
value={rows[i]?.note || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-1 text-[11.5px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت (در صورت نیاز) وارد کنید.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
|
||||
rows: RowDraft[];
|
||||
setRows: (r: RowDraft[]) => void;
|
||||
txs: InventoryTransaction[];
|
||||
whId: string;
|
||||
matTxt: string;
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
}) {
|
||||
if (!whId || !matTxt.trim()) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">برای مشاهده برداشتهای امروز، انبار و نام متریال را وارد کنید.</div>;
|
||||
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">امروز برداشتی برای این متریال در انبار انتخابی ثبت نشده است — چیزی برای برگشت وجود ندارد.</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-right text-muted">
|
||||
<th className="py-2 pe-3">مشخصهها</th>
|
||||
<th className="py-2 pe-3">برند</th>
|
||||
<th className="py-2 pe-3">واحد</th>
|
||||
<th className="py-2 pe-3">برداشت امروز</th>
|
||||
<th className="py-2 pe-3">تعداد برگشتی</th>
|
||||
<th className="py-2">یادداشت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{txs.map((t, i) => (
|
||||
<tr key={t.inventory_id + i} className="border-b border-line/50">
|
||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||
<td className="py-1.5 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
|
||||
<td className="py-1.5 pe-3">
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||
placeholder="۰"
|
||||
value={rows[i]?.qty || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<input
|
||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="توضیح برگشت"
|
||||
value={rows[i]?.note || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FoundTable({ rows, setRows, txs, whId, unitNameFn }: {
|
||||
rows: RowDraft[];
|
||||
setRows: (r: RowDraft[]) => void;
|
||||
txs: InventoryTransaction[];
|
||||
whId: string;
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
}) {
|
||||
if (!whId) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">ابتدا انبار را انتخاب کنید.</div>;
|
||||
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبتنشدهای (یا قابل پیداشدن) در این انبار وجود ندارد.</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-right text-muted">
|
||||
<th className="py-2 pe-3">متریال</th>
|
||||
<th className="py-2 pe-3">مشخصهها</th>
|
||||
<th className="py-2 pe-3">برند</th>
|
||||
<th className="py-2 pe-3">واحد</th>
|
||||
<th className="py-2 pe-3">مفقودی</th>
|
||||
<th className="py-2 pe-3">تعداد پیداشده</th>
|
||||
<th className="py-2">یادداشت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{txs.map((t, i) => (
|
||||
<tr key={t.inventory_id + i} className="border-b border-line/50">
|
||||
<td className="py-1.5 pe-3 font-semibold">{t.material?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||
<td className="py-1.5 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
|
||||
<td className="py-1.5 pe-3">
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||
placeholder="۰"
|
||||
value={rows[i]?.qty || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<input
|
||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="توضیح"
|
||||
value={rows[i]?.note || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
||||
import { useInventoryData, unitName, whName } from "./useInventoryData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
|
||||
import type { InventoryTransaction } from "../../lib/types";
|
||||
|
||||
function gregKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function todayKey(): string {
|
||||
return gregKey(new Date());
|
||||
}
|
||||
|
||||
// کنترل روزانه — پورت کامل تقویم شمسی نسخهی ارسالی
|
||||
export function DailyControlPage() {
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const data = useInventoryData();
|
||||
const navigate = useNavigate();
|
||||
const isPM = user?.role === "pm";
|
||||
|
||||
const [anchor, setAnchor] = useState<Date>(new Date());
|
||||
const [sel, setSel] = useState<string>(todayKey());
|
||||
const [actRec, setActRec] = useState<string | null>(null);
|
||||
|
||||
const txByDay = useMemo(() => {
|
||||
const map = new Map<string, InventoryTransaction[]>();
|
||||
data.transactions.forEach((t) => {
|
||||
const k = gregKey(new Date(t.transaction_date));
|
||||
const arr = map.get(k) || [];
|
||||
arr.push(t);
|
||||
map.set(k, arr);
|
||||
});
|
||||
map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()));
|
||||
return map;
|
||||
}, [data.transactions]);
|
||||
|
||||
// cells: 42 روز از شنبه قبلِ اولین روز ماه جلالی
|
||||
const cells = useMemo(() => {
|
||||
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
||||
let first: Date | null = null;
|
||||
for (let i = 0; i <= 33; i++) {
|
||||
const d = new Date(anchor.getTime() - i * 86400000);
|
||||
const [py, pm] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
if (py !== ty || pm !== tm) {
|
||||
first = new Date(anchor.getTime() - (i - 1) * 86400000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!first) first = new Date(anchor.getTime() - 33 * 86400000);
|
||||
const offset = (first.getDay() + 1) % 7; // شنبه = 0
|
||||
const gridStart = new Date(first.getTime() - offset * 86400000);
|
||||
const out: { d: Date; inMonth: boolean; isToday: boolean; n: number }[] = [];
|
||||
for (let c = 0; c < 42; c++) {
|
||||
const day = new Date(gridStart.getTime() + c * 86400000);
|
||||
const [jy, jm] = gregorianToJalali(day.getFullYear(), day.getMonth() + 1, day.getDate());
|
||||
const k = gregKey(day);
|
||||
out.push({
|
||||
d: day,
|
||||
inMonth: jy === ty && jm === tm,
|
||||
isToday: k === todayKey(),
|
||||
n: (txByDay.get(k) || []).length,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [anchor, txByDay]);
|
||||
|
||||
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
||||
const monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`;
|
||||
|
||||
const dayTx = txByDay.get(sel) || [];
|
||||
const withdrawals = dayTx.filter((t) => t.transaction_type === "WITHDRAWAL");
|
||||
const wGroups = useMemo(() => {
|
||||
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
|
||||
withdrawals.forEach((t) => {
|
||||
const g = gmap.get(t.inventory_id);
|
||||
if (!g) {
|
||||
gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
|
||||
} else {
|
||||
g.q += Math.abs(t.quantity_number || 0);
|
||||
if (t.transaction_date > g.last) g.last = t.transaction_date;
|
||||
}
|
||||
});
|
||||
return [...gmap.values()];
|
||||
}, [withdrawals]);
|
||||
|
||||
const isToday = sel === todayKey();
|
||||
const selDate = new Date(Number(sel.split("-")[0]), Number(sel.split("-")[1]) - 1, Number(sel.split("-")[2]));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="کنترل روزانه"
|
||||
sub={user?.project_name || ""}
|
||||
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* تقویم */}
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-[14px] font-bold">📅 {monthTitle}</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => setAnchor(new Date(anchor.getTime() + 33 * 86400000))}>◀ ماه بعد</button>
|
||||
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => { setAnchor(new Date()); setSel(todayKey()); setActRec(null); }}>امروز</button>
|
||||
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => setAnchor(new Date(anchor.getTime() - 33 * 86400000))}>▶ ماه قبل</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => (
|
||||
<div key={d} className="py-1 text-center text-[11px] font-bold text-muted">{d}</div>
|
||||
))}
|
||||
{cells.map((c, i) => {
|
||||
const [jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
|
||||
const k = gregKey(c.d);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => { setSel(k); setActRec(null); }}
|
||||
className={`relative rounded-lg py-2 text-[12px] ${!c.inMonth ? "text-muted/40" : ""} ${c.isToday ? "bg-accent/15 font-bold text-accent" : ""} ${sel === k ? "ring-2 ring-accent" : "hover:bg-accent/5"}`}
|
||||
>
|
||||
{toFa(jd)}
|
||||
{c.n > 0 && (
|
||||
<span className="absolute -top-0.5 -left-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red text-[9px] font-bold text-white">
|
||||
{toFa(c.n)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* برداشتهای روز */}
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h2 className="text-[14px] font-bold">🕘 کنترل روزانه — {isToday ? "امروز" : ""} {faNum(selDate.getDate())} {MONTHS[selDate.getMonth()]} {toFa(selDate.getFullYear())}</h2>
|
||||
<span className="rounded-lg bg-blue/10 px-2 py-1 text-[11.5px] font-bold text-blue">▼ برداشتها: {faNum(wGroups.length)} قلم</span>
|
||||
</div>
|
||||
<p className="mb-3 text-[11.5px] leading-5 text-muted">
|
||||
مواردی که این روز از انبار برداشت و به ساختمان برده شدهاند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید.
|
||||
</p>
|
||||
{!wGroups.length ? (
|
||||
<div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">
|
||||
{isToday ? "امروز هنوز برداشتی ثبت نشده است." : "در این روز برداشتی ثبت نشده است."} هر برداشتی که در «تغییر موجودی» ثبت کنید، همینجا نمایش داده میشود.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{wGroups.map((g) => (
|
||||
<div key={g.recId} className={`rounded-lg border p-2.5 ${actRec === g.recId ? "border-accent bg-accent/5" : "border-line bg-panel"}`}>
|
||||
<button type="button" className="flex w-full items-center gap-2 text-start" onClick={() => setActRec(actRec === g.recId ? null : g.recId)}>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-blue/10 text-blue">➖</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-semibold">{g.tx.material?.name || "—"}</span>
|
||||
<span className="block truncate text-[11px] text-muted">
|
||||
{[g.tx.brand?.name, Object.entries(g.tx.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · "), whName(data.warehouses, g.tx.warehouse_id)].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[13px] font-bold text-blue">−{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}</span>
|
||||
<span className="text-[10.5px] text-muted">{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||
</button>
|
||||
{actRec === g.recId && isPM && (
|
||||
<div className="mt-2 flex gap-1.5 border-t border-line pt-2">
|
||||
<button
|
||||
className="rounded-lg border border-green/30 bg-green/5 px-3 py-1 text-[11.5px] text-green"
|
||||
onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
||||
>
|
||||
↩️ برگشت
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-blue/30 bg-blue/5 px-3 py-1 text-[11.5px] text-blue"
|
||||
onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
||||
>
|
||||
📦 مفقودی
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-blue/30 bg-blue/5 px-3 py-1 text-[11.5px] text-blue"
|
||||
onClick={() => navigate(`/inventory/change?type=WASTE&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
||||
>
|
||||
🗑️ اتلاف
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
||||
import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData";
|
||||
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
|
||||
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
|
||||
PURCHASE: { label: "خرید", color: "#16a34a", icon: "🛒" },
|
||||
WITHDRAWAL: { label: "برداشت", color: "#1d4ed8", icon: "➖" },
|
||||
RETURN: { label: "برگشت", color: "#16a34a", icon: "↩️" },
|
||||
TRANSFER_IN: { label: "انتقال (ورودی)", color: "#16a34a", icon: "⬅️" },
|
||||
TRANSFER_OUT: { label: "انتقال (خروجی)", color: "#1d4ed8", icon: "➡️" },
|
||||
DAMAGE: { label: "ضایعات", color: "#1d4ed8", icon: "⚠️" },
|
||||
WASTE: { label: "اتلاف", color: "#1d4ed8", icon: "🗑️" },
|
||||
LOSS: { label: "مفقودی", color: "#1d4ed8", icon: "📦" },
|
||||
FOUND: { label: "پیدا شده", color: "#16a34a", icon: "🔍" },
|
||||
STOCK_COUNT: { label: "شمارش موجودی", color: "#64748b", icon: "✅" },
|
||||
};
|
||||
|
||||
interface Group {
|
||||
key: string;
|
||||
whId: string;
|
||||
matId: string;
|
||||
specs: Record<string, string>;
|
||||
unitId: number | null;
|
||||
qty: number;
|
||||
updatedAt: string;
|
||||
recs: InventoryRow[];
|
||||
brands: Record<string, number>;
|
||||
brandOrder: string[];
|
||||
}
|
||||
|
||||
function specsHash(specs: Record<string, string> | null | undefined): string {
|
||||
const s = specs || {};
|
||||
const keys = Object.keys(s).filter((k) => s[k] !== "" && s[k] != null).sort();
|
||||
if (!keys.length) return "default";
|
||||
return keys.map((k) => `${k}=${s[k]}`).join("|");
|
||||
}
|
||||
|
||||
// داشبورد انبار — پورت کامل نسخهی ارسالی (گروهبندی + خط زمانی + فیلترها)
|
||||
export function InventoryPage() {
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
|
||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; // مدیر تدارکات = ادمین دفتر
|
||||
const isPM = user?.role === "pm";
|
||||
|
||||
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({
|
||||
category: "", warehouse: "", material: "", project: "",
|
||||
});
|
||||
const [sort, setSort] = useState<"asc" | "desc" | "">("");
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
const [selTx, setSelTx] = useState<string | null>(null);
|
||||
const [expBrand, setExpBrand] = useState<string | null>(null);
|
||||
|
||||
// ادمین: انبارهای همه پروژهها یا پروژهی انتخابی
|
||||
const visibleWarehouses = useMemo(() => {
|
||||
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
|
||||
if (isAdmin) return warehouses;
|
||||
return warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""));
|
||||
}, [warehouses, isAdmin, filters.project, user?.project_id]);
|
||||
|
||||
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
|
||||
|
||||
const groups = useMemo<Group[]>(() => {
|
||||
const gmap = new Map<string, Group>();
|
||||
const order: string[] = [];
|
||||
inventory.forEach((r) => {
|
||||
const whId = r.warehouse?.id;
|
||||
if (!whId || !visibleIds.has(whId)) return;
|
||||
if (filters.category && r.material.category?.id !== filters.category) return;
|
||||
if (filters.warehouse && whId !== filters.warehouse) return;
|
||||
if (filters.material && !r.material.name.includes(filters.material.trim())) return;
|
||||
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
|
||||
let g = gmap.get(gk);
|
||||
if (!g) {
|
||||
g = { key: gk, whId, matId: r.material.id, specs: r.specs || {}, unitId: r.unit?.id ?? null, qty: 0, updatedAt: r.updated_at, recs: [], brands: {}, brandOrder: [] };
|
||||
gmap.set(gk, g);
|
||||
order.push(gk);
|
||||
}
|
||||
g.qty += r.quantity_number || 0;
|
||||
if (r.updated_at > g.updatedAt) g.updatedAt = r.updated_at;
|
||||
g.recs.push(r);
|
||||
if (r.brand) {
|
||||
if (!(r.brand.id in g.brands)) {
|
||||
g.brands[r.brand.id] = 0;
|
||||
g.brandOrder.push(r.brand.id);
|
||||
}
|
||||
g.brands[r.brand.id] += r.quantity_number || 0;
|
||||
}
|
||||
});
|
||||
const out = order.map((k) => gmap.get(k)!);
|
||||
if (sort === "asc") out.sort((a, b) => a.qty - b.qty);
|
||||
else if (sort === "desc") out.sort((a, b) => b.qty - a.qty);
|
||||
else out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
return out;
|
||||
}, [inventory, visibleIds, filters, sort]);
|
||||
|
||||
const groupTxs = (g: Group) =>
|
||||
transactions
|
||||
.filter((t) => g.recs.some((r) => r.id === t.inventory_id))
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
|
||||
const brandTxs = (g: Group, bid: string) =>
|
||||
transactions
|
||||
.filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id))
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
|
||||
const hasFilter = !!(filters.category || filters.warehouse || filters.material || (isAdmin && filters.project));
|
||||
|
||||
if (loading) return <div className="min-h-screen" />;
|
||||
if (!user || (!isAdmin && !isPM)) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="داشبورد انبار" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<PageLayout>
|
||||
<EmptyState emoji="🔒" text="دسترسی به انبار فقط برای مدیر پروژه یا ادمین است." />
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="سامانه مدیریت انبار"
|
||||
sub={isAdmin ? "فقطخواندنی" : user?.project_name ? `مدیر پروژه — ${user.project_name}` : "مدیر پروژه"}
|
||||
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
{/* فیلترها */}
|
||||
<div className="flex flex-wrap items-end gap-2 rounded-xl border border-line bg-panel p-3">
|
||||
<div className="field">
|
||||
<label>دستهبندی</label>
|
||||
<select
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
||||
value={filters.category}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
|
||||
>
|
||||
<option value="">همه دستهبندیها</option>
|
||||
{lookup.categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="field">
|
||||
<label>پروژه</label>
|
||||
<select
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
||||
value={filters.project}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
|
||||
>
|
||||
<option value="">همه پروژهها</option>
|
||||
{lookup.projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<label>انبار</label>
|
||||
<select
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
||||
value={filters.warehouse}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
|
||||
>
|
||||
<option value="">همه انبارها</option>
|
||||
{visibleWarehouses.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}{isAdmin ? ` — ${lookup.projects.find((p) => p.id === w.project_id)?.name || ""}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>متریال</label>
|
||||
<input
|
||||
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
||||
placeholder="تایپ نام متریال…"
|
||||
value={filters.material}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, material: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="h-8 rounded-lg border border-line bg-panel px-3 text-[12px]"
|
||||
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
|
||||
>
|
||||
{sort === "asc" ? "تعداد: صعودی ▲" : sort === "desc" ? "تعداد: نزولی ▼" : "مرتبسازی تعداد"}
|
||||
</button>
|
||||
{hasFilter && (
|
||||
<button
|
||||
className="h-8 rounded-lg border border-red/30 bg-red/5 px-3 text-[12px] text-red"
|
||||
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
||||
>
|
||||
✕ پاک کردن فیلترها
|
||||
</button>
|
||||
)}
|
||||
<Badge variant="outline">{faNum(groups.length)} قلم کالا</Badge>
|
||||
</div>
|
||||
|
||||
{/* جدول گروهبندیشده */}
|
||||
<div className="overflow-hidden rounded-xl border border-line bg-panel">
|
||||
{!groups.length ? (
|
||||
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
||||
) : (
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel text-right text-muted">
|
||||
<th className="w-8 py-2" />
|
||||
<th className="py-2 pe-3">متریال</th>
|
||||
<th className="py-2 pe-3">دستهبندی</th>
|
||||
<th className="py-2 pe-3">انبار</th>
|
||||
<th className="py-2 pe-3">مشخصه فنی</th>
|
||||
<th className="py-2 pe-3">تعداد</th>
|
||||
<th className="py-2 pe-3">واحد</th>
|
||||
<th className="py-2 pe-3">آخرین بهروزرسانی</th>
|
||||
<th className="w-8 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((g) => {
|
||||
const isExp = expandedKey === g.key;
|
||||
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
|
||||
return (
|
||||
<GroupRow
|
||||
key={g.key}
|
||||
g={g}
|
||||
isExp={isExp}
|
||||
isAdmin={isAdmin}
|
||||
matName={materialName(lookup.materials, g.matId)}
|
||||
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
|
||||
wh={whName(warehouses, g.whId)}
|
||||
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
||||
unit={unitName(lookup.units, g.unitId)}
|
||||
onToggle={() => {
|
||||
setExpandedKey(isExp ? null : g.key);
|
||||
setSelTx(null);
|
||||
setExpBrand(null);
|
||||
}}
|
||||
timeline={
|
||||
isExp ? (
|
||||
<Timeline
|
||||
g={g}
|
||||
txs={groupTxs(g)}
|
||||
allTxs={transactions}
|
||||
brandTxs={brandTxs}
|
||||
selTx={selTx}
|
||||
setSelTx={setSelTx}
|
||||
expBrand={expBrand}
|
||||
setExpBrand={setExpBrand}
|
||||
matName={materialName(lookup.materials, g.matId)}
|
||||
unit={unitName(lookup.units, g.unitId)}
|
||||
brandNameFn={(id) => brandName(lookup.brands, id)}
|
||||
whNameFn={(id) => whName(warehouses, id)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
۱۰ نوع تراکنش استاندارد • افزایش موجودی سبز، کاهش آبی • کلیک روی هر سطر: خط زمانی با «مانده» لحظهبهلحظه و تفکیک برندها
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: {
|
||||
g: Group;
|
||||
isExp: boolean;
|
||||
isAdmin: boolean;
|
||||
matName: string;
|
||||
matCatName: string;
|
||||
wh: string;
|
||||
projName: string;
|
||||
unit: string;
|
||||
onToggle: () => void;
|
||||
timeline: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr className={`border-b border-line/60 cursor-pointer ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
|
||||
<td className="py-2 text-center text-muted">{isExp ? "▾" : "▸"}</td>
|
||||
<td className="py-2 pe-3 font-semibold">{matName}</td>
|
||||
<td className="py-2 pe-3">{matCatName}</td>
|
||||
<td className="py-2 pe-3">
|
||||
{wh}
|
||||
{isAdmin && <small className="block text-muted">{projName}</small>}
|
||||
</td>
|
||||
<td className="py-2 pe-3 text-muted">
|
||||
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
|
||||
</td>
|
||||
<td className="py-2 pe-3 font-bold">{faNum(g.qty)}</td>
|
||||
<td className="py-2 pe-3">{unit}</td>
|
||||
<td className="py-2 pe-3 text-muted">{isoToJalaliStr(g.updatedAt)}</td>
|
||||
<td className="py-2 text-muted">🕘</td>
|
||||
</tr>
|
||||
{isExp && (
|
||||
<tr className="border-b border-line/40 bg-accent-bg/20">
|
||||
<td colSpan={9} className="p-3">{timeline}</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpBrand, matName, unit, brandNameFn, whNameFn }: {
|
||||
g: Group;
|
||||
txs: InventoryTransaction[];
|
||||
allTxs: InventoryTransaction[];
|
||||
brandTxs: (g: Group, bid: string) => InventoryTransaction[];
|
||||
selTx: string | null;
|
||||
setSelTx: (id: string | null) => void;
|
||||
expBrand: string | null;
|
||||
setExpBrand: (id: string | null) => void;
|
||||
matName: string;
|
||||
unit: string;
|
||||
brandNameFn: (id: string | null | undefined) => string;
|
||||
whNameFn: (id: string | null | undefined) => string;
|
||||
}) {
|
||||
const track = (list: InventoryTransaction[]) => {
|
||||
let bal = 0;
|
||||
return list.map((t) => {
|
||||
const meta = TX_META[t.transaction_type] || { label: t.transaction_type, color: "#64748b", icon: "•" };
|
||||
bal += t.quantity_number || 0;
|
||||
const sel = selTx === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setSelTx(sel ? null : t.id)}
|
||||
className={`flex min-w-[120px] flex-col gap-0.5 rounded-lg border p-2 text-start ${sel ? "border-accent bg-accent/10" : "border-line bg-panel"}`}
|
||||
style={{ borderTop: `3px solid ${meta.color}` }}
|
||||
>
|
||||
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
|
||||
<span className="text-[12px] font-bold" style={{ color: meta.color }}>
|
||||
{t.quantity_number >= 0 ? "+" : "−"}{faNum(Math.abs(t.quantity_number))} {unit}
|
||||
</span>
|
||||
<span className="text-[10.5px] text-muted">مانده: <b>{faNum(bal)}</b> {unit}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (!txs.length) return <div className="text-[12px] text-muted">تراکنشی ثبت نشده است.</div>;
|
||||
|
||||
const brandSec = expBrand ? (
|
||||
<div className="mt-2">
|
||||
<div className="mb-1 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div>
|
||||
<div className="flex gap-1.5 overflow-x-auto">{track(brandTxs(g, expBrand))}</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const selDetail = selTx ? (() => {
|
||||
const t = allTxs.find((x) => x.id === selTx);
|
||||
if (!t) return null;
|
||||
const meta = TX_META[t.transaction_type] || { label: t.transaction_type, color: "#64748b", icon: "•" };
|
||||
const pairTx = t.transfer_pair_id ? allTxs.find((x) => x.transfer_pair_id === t.transfer_pair_id && x.id !== t.id) : null;
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border p-2 text-[12px]" style={{ borderTop: `3px solid ${meta.color}` }}>
|
||||
<b style={{ color: meta.color }}>{meta.label}</b>
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted">
|
||||
<span>تاریخ و ساعت: <b>{isoToJalaliStr(t.transaction_date)}</b></span>
|
||||
{t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? (
|
||||
<>
|
||||
<span>از انبار: <b>{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
|
||||
<span>به انبار: <b>{t.transaction_type === "TRANSFER_IN" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
|
||||
</>
|
||||
) : (
|
||||
<span>انبار: <b>{whNameFn(t.warehouse_id)}</b></span>
|
||||
)}
|
||||
{t.brand?.name && <span>برند: <b>{t.brand.name}</b></span>}
|
||||
{t.performed_by && <span>انجامدهنده: <b>{t.performed_by}</b></span>}
|
||||
{t.note && <span>یادداشت: <b>{t.note}</b></span>}
|
||||
{t.transaction_type === "STOCK_COUNT" && t.recorded_qty != null && t.physical_qty != null && (
|
||||
<span>
|
||||
ثبتشده: {faNum(t.recorded_qty)} • شمارششده: {faNum(t.physical_qty)} • اختلاف: {faNum(t.physical_qty - t.recorded_qty)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})() : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-[12px]">
|
||||
<b>خط زمانی — {matName}</b>
|
||||
{Object.entries(g.specs || {}).length > 0 && (
|
||||
<span className="rounded bg-panel px-1.5 py-0.5 text-[10.5px] text-muted">
|
||||
{Object.entries(g.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded bg-panel px-1.5 py-0.5 text-[10.5px] text-muted">{faNum(g.brandOrder.length)} برند</span>
|
||||
</div>
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{g.brandOrder.map((bid) => (
|
||||
<button
|
||||
key={bid}
|
||||
type="button"
|
||||
onClick={() => setExpBrand(expBrand === bid ? null : bid)}
|
||||
className={`rounded-lg border px-2 py-1 text-[11.5px] ${expBrand === bid ? "border-accent bg-accent/10 text-accent" : "border-line bg-panel"}`}
|
||||
>
|
||||
{brandNameFn(bid)}: <b>{faNum(g.brands[bid])}</b> {unit}{expBrand === bid ? " ✕" : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{brandSec}
|
||||
<div className="mb-1 mt-2 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div>
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1">{track(txs)}</div>
|
||||
{selDetail}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import { useInventoryData } from "./useInventoryData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
|
||||
// سازماندهی (فقط ادمین) — پروژهها، مدیران و انبارهای هر پروژه
|
||||
export function OrganizationPage() {
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const data = useInventoryData();
|
||||
|
||||
const [newWh, setNewWh] = useState<Record<string, string>>({});
|
||||
const [newWhDesc, setNewWhDesc] = useState<Record<string, string>>({});
|
||||
|
||||
if (user?.role !== "admin" && user?.role !== "procurement_manager") {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="سازماندهی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<PageLayout>
|
||||
<div className="text-[13px] text-muted">این بخش فقط برای ادمین است.</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const addWarehouse = async (projectId: string) => {
|
||||
const name = (newWh[projectId] || "").trim();
|
||||
if (!name) return toast.error("نام انبار الزامی است");
|
||||
try {
|
||||
await apiV26.warehouses.create({ project_id: projectId, name, description: (newWhDesc[projectId] || "").trim() || null });
|
||||
setNewWh((s) => ({ ...s, [projectId]: "" }));
|
||||
setNewWhDesc((s) => ({ ...s, [projectId]: "" }));
|
||||
toast.success(`انبار «${name}» اضافه شد ✓`);
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در افزودن انبار");
|
||||
}
|
||||
};
|
||||
|
||||
const moveWarehouse = async (id: string, projectId: string) => {
|
||||
try {
|
||||
await apiV26.warehouses.update(id, { project_id: projectId });
|
||||
toast.success("انبار منتقل شد ✓");
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در انتقال انبار");
|
||||
}
|
||||
};
|
||||
|
||||
const removeWarehouse = async (id: string) => {
|
||||
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
||||
try {
|
||||
await apiV26.warehouses.delete(id);
|
||||
toast.success("انبار حذف شد");
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در حذف انبار");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="سازماندهی" sub="ادمین" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||
ℹ️ پروژهها از سیستم تدارکات میآیند. اینجا مشخص میکنید هر پروژه با کدام مدیر و انبار(ها) کار میکند؛
|
||||
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرمها اعمال میشود.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{data.lookup.projects.map((p) => {
|
||||
const whs = data.warehouses.filter((w) => w.project_id === p.id);
|
||||
return (
|
||||
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-[14px] font-bold">🏢 {p.name}</h3>
|
||||
<span className="rounded-lg bg-panel px-2 py-1 text-[11px] text-muted">
|
||||
{faNum(whs.length)} انبار
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-1 text-[12px] font-bold text-muted">👤 مدیر پروژه</h4>
|
||||
{p.pm_user_id ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
||||
<span className="font-semibold">{p.pm_name || "—"}</span>
|
||||
<span className="text-[10.5px] text-muted">(مدیر مسئول پروژه)</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">
|
||||
مدیری تعیین نشده است — از بخش کاربران (Casdoor) نقش مدیر پروژه را به کاربر بدهید و در تنظیمات پروژه وصل کنید.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h4 className="mb-1 text-[12px] font-bold text-muted">🏬 انبارهای پروژه</h4>
|
||||
{!whs.length ? (
|
||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">انباری ندارد — از فرم زیر اضافه کنید.</div>
|
||||
) : (
|
||||
<div className="mb-2 flex flex-col gap-1.5">
|
||||
{whs.map((w) => (
|
||||
<div key={w.id} className="flex items-center justify-between gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
||||
<div className="min-w-0">
|
||||
<b>{w.name}</b>
|
||||
<small className="block truncate text-muted">{w.description || ""}</small>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
|
||||
value={w.project_id}
|
||||
onChange={(e) => void moveWarehouse(w.id, e.target.value)}
|
||||
title="انتقال به پروژه دیگر"
|
||||
>
|
||||
{data.lookup.projects.map((p2) => (
|
||||
<option key={p2.id} value={p2.id}>{p2.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="نام انبار جدید"
|
||||
value={newWh[p.id] || ""}
|
||||
onChange={(e) => setNewWh((s) => ({ ...s, [p.id]: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="h-8 w-52 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="توضیحات (اختیاری)"
|
||||
value={newWhDesc[p.id] || ""}
|
||||
onChange={(e) => setNewWhDesc((s) => ({ ...s, [p.id]: e.target.value }))}
|
||||
/>
|
||||
<button className="rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-white" onClick={() => void addWarehouse(p.id)}>
|
||||
+ افزودن انبار
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import { useInventoryData, projName } from "./useInventoryData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
|
||||
// انبارها — پورت کامل نسخهی ارسالی (مدیر پروژه: افزودن/ویرایش/حذف)
|
||||
export function WarehousesPage() {
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const data = useInventoryData();
|
||||
const isAdmin = user?.role === "admin";
|
||||
const isPM = user?.role === "pm";
|
||||
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newDesc, setNewDesc] = useState("");
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDesc, setEditDesc] = useState("");
|
||||
|
||||
const addWarehouse = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name) return toast.error("نام انبار الزامی است");
|
||||
const projectId = user?.project_id;
|
||||
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
|
||||
try {
|
||||
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
|
||||
setNewName("");
|
||||
setNewDesc("");
|
||||
toast.success(`انبار «${name}» اضافه شد ✓`);
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در افزودن انبار");
|
||||
}
|
||||
};
|
||||
|
||||
const saveEdit = async (id: string) => {
|
||||
if (!editName.trim()) return toast.error("نام انبار نمیتواند خالی باشد");
|
||||
try {
|
||||
await apiV26.warehouses.update(id, { name: editName.trim(), description: editDesc.trim() || null });
|
||||
setEditId(null);
|
||||
toast.success("تغییرات ذخیره شد");
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ذخیره");
|
||||
}
|
||||
};
|
||||
|
||||
const removeWarehouse = async (id: string) => {
|
||||
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
||||
try {
|
||||
await apiV26.warehouses.delete(id);
|
||||
toast.success("انبار حذف شد");
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در حذف انبار");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="انبارها"
|
||||
sub={user?.project_name || ""}
|
||||
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
{isAdmin && (
|
||||
<div className="rounded-lg border border-amber/30 bg-amber/5 px-3 py-2 text-[12px] text-amber">
|
||||
🔒 نقش ادمین فقطخواندنی است — مدیریت انبارها با مدیران پروژه است.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPM && (
|
||||
<div className="rounded-xl border border-line bg-panel p-3">
|
||||
<h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="نام انبار (مثلاً انبار موقت)"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-9 w-64 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="توضیحات (اختیاری)"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
/>
|
||||
<button className="rounded-lg bg-accent px-4 py-2 text-[12.5px] font-bold text-white" onClick={() => void addWarehouse()}>
|
||||
+ افزودن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data.warehouses.map((w) => {
|
||||
const cnt = data.inventory.filter((r) => r.warehouse?.id === w.id).length;
|
||||
const txc = data.transactions.filter((t) => t.warehouse_id === w.id).length;
|
||||
return (
|
||||
<div key={w.id} className="rounded-xl border border-line bg-panel p-4">
|
||||
<h3 className="text-[14px] font-bold">🏬 {w.name}</h3>
|
||||
<div className="mt-0.5 text-[11.5px] text-muted">{w.description || "—"}</div>
|
||||
<div className="mt-2 flex flex-col gap-1 text-[12px] text-muted">
|
||||
<span>پروژه: <b className="text-foreground">{projName(data.lookup.projects, w.project_id)}</b></span>
|
||||
<span>اقلام موجود: <b className="text-foreground">{faNum(cnt)}</b></span>
|
||||
<span>تراکنشها: <b className="text-foreground">{faNum(txc)}</b></span>
|
||||
<span>تاریخ ایجاد: <b className="text-foreground">{isoToJalaliStr(w.created_at)}</b></span>
|
||||
</div>
|
||||
{isPM && (
|
||||
<div className="mt-3 flex gap-1.5 border-t border-line pt-2">
|
||||
<button
|
||||
className="rounded-lg border border-line px-3 py-1 text-[11.5px]"
|
||||
onClick={() => { setEditId(w.id); setEditName(w.name); setEditDesc(w.description || ""); }}
|
||||
>
|
||||
✏️ ویرایش
|
||||
</button>
|
||||
<button className="rounded-lg border border-red/30 px-3 py-1 text-[11.5px] text-red" onClick={() => void removeWarehouse(w.id)}>
|
||||
✕ حذف
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{editId === w.id && isPM && (
|
||||
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-dashed border-accent/40 p-2">
|
||||
<input
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="نام انبار"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="توضیحات"
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="rounded-lg bg-accent px-3 py-1 text-[11.5px] font-bold text-white" onClick={() => void saveEdit(w.id)}>ذخیره</button>
|
||||
<button className="rounded-lg border border-line px-3 py-1 text-[11.5px]" onClick={() => setEditId(null)}>انصراف</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { MenuItem } from "../../components/PanelMenu";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
|
||||
// خروج از حساب — پاککردن نشست و بازگشت به صفحهی ورود
|
||||
export function useLogout() {
|
||||
return () => {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = "/login";
|
||||
};
|
||||
}
|
||||
|
||||
// آیتمهای منوی همبرگری پنل مدیریت انبار — با نشانگر پنل فعلی + «بازگشت به دفتر» + خروج
|
||||
export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] {
|
||||
const isAdmin = role === "admin" || role === "procurement_manager";
|
||||
const isPM = role === "pm";
|
||||
const items: MenuItem[] = [
|
||||
{ label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" },
|
||||
{ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") },
|
||||
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
|
||||
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") },
|
||||
];
|
||||
if (isAdmin) {
|
||||
items.push({ label: "سازماندهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") });
|
||||
}
|
||||
if (isPM) {
|
||||
items.push({ label: "رسید کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
|
||||
}
|
||||
items.push({ divider: true });
|
||||
items.push({ label: "بازگشت به دفتر", to: "/" });
|
||||
if (onLogout) {
|
||||
items.push({ divider: true });
|
||||
items.push({ label: "خروج از حساب", onClick: onLogout });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// ============================================================
|
||||
// Inventory data hook — loads all dash data (scoped by role/project)
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types";
|
||||
|
||||
export interface LookupData {
|
||||
categories: { id: string; slug: string; name: string }[];
|
||||
units: { id: number; code: string; name_fa: string | null }[];
|
||||
brands: { id: string; name: string }[];
|
||||
materials: { id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
|
||||
projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[];
|
||||
}
|
||||
|
||||
export interface InventoryData {
|
||||
warehouses: Warehouse[];
|
||||
inventory: InventoryRow[];
|
||||
transactions: InventoryTransaction[];
|
||||
lookup: LookupData;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useInventoryData(): InventoryData {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isPM = user?.role === "pm";
|
||||
const projectId = isPM ? user.project_id || undefined : undefined;
|
||||
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
const [inventory, setInventory] = useState<InventoryRow[]>([]);
|
||||
const [transactions, setTransactions] = useState<InventoryTransaction[]>([]);
|
||||
const [lookup, setLookup] = useState<LookupData>({
|
||||
categories: [], units: [], brands: [], materials: [], projects: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [cats, units, brands, mats, projs, whs, inv, txs] = await Promise.all([
|
||||
apiV26.lookup.categories(),
|
||||
apiV26.lookup.units(),
|
||||
apiV26.lookup.brands(),
|
||||
apiV26.lookup.materials(),
|
||||
apiV26.lookup.projects(),
|
||||
apiV26.warehouses.list(projectId),
|
||||
apiV26.inventory.list({ project_id: projectId }),
|
||||
apiV26.inventory.transactions({ project_id: projectId }),
|
||||
]);
|
||||
setLookup({ categories: cats, units, brands, materials: mats, projects: projs });
|
||||
setWarehouses(whs);
|
||||
setInventory(inv);
|
||||
setTransactions(txs);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const scopedWarehouses = useMemo(
|
||||
() => (user?.role === "admin" ? warehouses : warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""))),
|
||||
[warehouses, user?.role, user?.project_id],
|
||||
);
|
||||
|
||||
return {
|
||||
warehouses: scopedWarehouses,
|
||||
inventory,
|
||||
transactions,
|
||||
lookup,
|
||||
loading,
|
||||
refresh: load,
|
||||
};
|
||||
}
|
||||
|
||||
export function unitName(units: LookupData["units"], id: number | null | undefined): string {
|
||||
const u = units.find((x) => x.id === id);
|
||||
return u ? u.name_fa || u.code : "—";
|
||||
}
|
||||
|
||||
export function brandName(brands: LookupData["brands"], id: string | null | undefined): string {
|
||||
const b = brands.find((x) => x.id === id);
|
||||
return b ? b.name : "—";
|
||||
}
|
||||
|
||||
export function materialName(materials: LookupData["materials"], id: string): string {
|
||||
const m = materials.find((x) => x.id === id);
|
||||
return m ? m.name : "—";
|
||||
}
|
||||
|
||||
export function catName(categories: LookupData["categories"], id: string | null | undefined): string {
|
||||
const c = categories.find((x) => x.id === id);
|
||||
return c ? c.name : "—";
|
||||
}
|
||||
|
||||
export function projName(projects: LookupData["projects"], id: string | null | undefined): string {
|
||||
const p = projects.find((x) => x.id === id);
|
||||
return p ? p.name : "—";
|
||||
}
|
||||
|
||||
export function whName(warehouses: Warehouse[], id: string | null | undefined): string {
|
||||
const w = warehouses.find((x) => x.id === id);
|
||||
return w ? w.name : "—";
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Building2, Inbox, CheckCircle2, Truck, Phone, FolderKanban } from "lucide-react";
|
||||
import type { Project, Quote, Store } from "../../lib/types";
|
||||
import { nextId } from "../../lib/api";
|
||||
import {
|
||||
useAddProject,
|
||||
useMaterials,
|
||||
useProjects,
|
||||
useQuotes,
|
||||
useStoreData,
|
||||
useSuppliers,
|
||||
useUnits,
|
||||
} from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import {
|
||||
activeItems,
|
||||
lineItemTotal,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
refConverted,
|
||||
supplierById,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { faNum, fmtDate } from "../../lib/formatters";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
||||
return quotes.filter((q) => (q.projects || []).includes(name) || q.project === name);
|
||||
}
|
||||
|
||||
// اقلام مربوط به این پروژه در یک quote
|
||||
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
||||
if (items.some((li) => li.projects && li.projects.length)) {
|
||||
return items.filter((li) => (li.projects || []).some((p) => p.name === name));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function qTotal(q: Quote): number {
|
||||
return activeItems(q).reduce((sum, li) => {
|
||||
const t = lineItemTotal(li);
|
||||
return sum + (t || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function daysSince(iso: string | null): number | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
// صفحهی پروژهها — لیست + پنل جزئیات ۳ ستونه
|
||||
export function ProjectsPage() {
|
||||
useStoreData();
|
||||
const projects = useProjects();
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
||||
const addProject = useAddProject();
|
||||
|
||||
const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions };
|
||||
|
||||
const [openProject, setOpenProject] = useState<string | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
const addProj = () => {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
const dup = projects.some((p) => normalize(p.name) === normalize(name));
|
||||
if (dup) {
|
||||
toast.error("این پروژه قبلاً ثبت شده است.");
|
||||
return;
|
||||
}
|
||||
const p: Project = { id: nextId("p", projects), name };
|
||||
void addProject.mutateAsync(p);
|
||||
setNewName("");
|
||||
setOpenProject(name);
|
||||
toast.success("پروژه افزوده شد.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="پروژهها" sub="روی یک پروژه کلیک کنید تا جزئیات آن باز شود" backTo="/" backLabel="بازگشت به دفتر" />
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
<div className="add-panel">
|
||||
<h4>+ افزودن پروژهی جدید</h4>
|
||||
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
|
||||
<div className="field">
|
||||
<label>نام پروژه *</label>
|
||||
<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={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addProj();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={addProj}>افزودن پروژه</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!projects.length && !quotes.length ? (
|
||||
<EmptyState icon={FolderKanban} text="هنوز پروژهای ثبت نشده است." />
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{projects.map((p) => {
|
||||
const count = projectQuotes(quotes, p.name).length;
|
||||
const open = openProject === p.name;
|
||||
return (
|
||||
<div key={p.id} className="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={`proj-item ${open ? "open" : ""}`}
|
||||
onClick={() => setOpenProject(open ? null : p.name)}
|
||||
>
|
||||
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<span className="proj-count">{faNum(count)} پیشنهاد</span>
|
||||
</button>
|
||||
{/* جزئیات دقیقاً زیر همان پروژه باز میشود */}
|
||||
{open && <div className="ms-2">{renderProjectDetail(store, p.name)}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- پنل جزئیات یک پروژه ----------
|
||||
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||
const quotes = projectQuotes(store.quotes, name);
|
||||
const sumBy = (st: Quote["status"]) =>
|
||||
quotes
|
||||
.filter((q) => q.status === st)
|
||||
.reduce((s, q) => s + qTotal(q), 0);
|
||||
|
||||
const matName = (mid: string) => {
|
||||
const m = materialById(store.materials, mid);
|
||||
if (m.category === "؟") return "؟";
|
||||
const specs = materialSpecs(m);
|
||||
return `${m.category} ${m.brand}${specs.length ? " · " + specs.join(" · ") : ""}`;
|
||||
};
|
||||
|
||||
const supName = (sid: string) => {
|
||||
const s = supplierById(store.suppliers, sid);
|
||||
if (s.name === "؟") return "؟";
|
||||
return (
|
||||
<Link to={`/suppliers/${sid}`} className="pj-sup-link">
|
||||
{s.name}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const itemLine = (li: Quote["line_items"][number]) => {
|
||||
const total = lineItemTotal(li);
|
||||
const conv = refConverted(store, li);
|
||||
return (
|
||||
<div key={li.id}>
|
||||
{matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "}
|
||||
<b>{faNum(total || 0)} تومان</b>
|
||||
{conv && (
|
||||
<span className="pj-conv">
|
||||
{" "}
|
||||
— <span className="pj-conv">≈ {faNum(conv.qty)} {conv.unit}({faNum(conv.perRef ?? 0)} تومان/{conv.unit})</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const offered = quotes
|
||||
.filter((q) => q.status === "offered")
|
||||
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
|
||||
const accepted = quotes.filter((q) => q.status === "accepted");
|
||||
const arrived = quotes.filter((q) => q.status === "arrived");
|
||||
|
||||
const offeredRow = (q: Quote) => {
|
||||
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
|
||||
return (
|
||||
<div key={q.id} className="pj-row">
|
||||
<div className="pj-main">
|
||||
<Phone className="me-1 inline h-3 w-3 text-muted" /> {supName(q.supplier_id)}
|
||||
</div>
|
||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||
<div className="pj-side">
|
||||
جمع: <b>{faNum(qTotal(q))} تومان</b>
|
||||
</div>
|
||||
<div className="pj-side">
|
||||
اعتبار تا {fmtDate(q.expiry_date)}<br />
|
||||
{q.expiry_date == null ? (
|
||||
<span className="pj-chip">بدون انقضا</span>
|
||||
) : days != null && days < 0 ? (
|
||||
<span className="pj-chip red">منقضی شده</span>
|
||||
) : days === 0 ? (
|
||||
<span className="pj-chip amber">امروز آخرین مهلت</span>
|
||||
) : (
|
||||
<span className="pj-chip amber">{faNum(days)} روز مانده</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const acceptedRow = (q: Quote) => {
|
||||
const d = daysSince(q.accepted_date);
|
||||
return (
|
||||
<div key={q.id} className="pj-row">
|
||||
<div className="pj-main">
|
||||
<Phone className="me-1 inline h-3 w-3 text-muted" /> {supName(q.supplier_id)}
|
||||
</div>
|
||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||
<div className="pj-side">
|
||||
تایید: {fmtDate(q.accepted_date)}
|
||||
{d != null && <span className="pj-chip green">{faNum(d)} روز پیش</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const arrivedRow = (q: Quote) => (
|
||||
<div key={q.id} className="pj-row">
|
||||
<div className="pj-main">
|
||||
<Phone className="me-1 inline h-3 w-3 text-muted" /> {supName(q.supplier_id)}
|
||||
</div>
|
||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||
<div className="pj-side">
|
||||
هزینه: <b>{faNum(qTotal(q))} تومان</b>
|
||||
</div>
|
||||
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-line bg-panel p-4 shadow-sm">
|
||||
<h3 className="mb-2 text-[15px] font-bold">
|
||||
<Building2 className="me-1.5 inline h-4 w-4 text-accent" />
|
||||
{name}
|
||||
</h3>
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
|
||||
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
|
||||
ارزش باز: {faNum(sumBy("offered"))} تومان
|
||||
</span>
|
||||
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
|
||||
ارزش پذیرفته: {faNum(sumBy("accepted"))} تومان
|
||||
</span>
|
||||
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
|
||||
ارزش رسیده: {faNum(sumBy("arrived"))} تومان
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<div className="pj-col">
|
||||
<div className="pj-col-h">
|
||||
<Inbox className="me-1 inline h-3.5 w-3.5 text-[#1d4ed8]" /> پیشنهاد شده ({faNum(offered.length)})
|
||||
</div>
|
||||
{offered.length ? offered.map(offeredRow) : <div className="pj-empty">—</div>}
|
||||
</div>
|
||||
<div className="pj-col">
|
||||
<div className="pj-col-h">
|
||||
<CheckCircle2 className="me-1 inline h-3.5 w-3.5 text-amber" /> پذیرفته شده ({faNum(accepted.length)})
|
||||
</div>
|
||||
{accepted.length ? accepted.map(acceptedRow) : <div className="pj-empty">—</div>}
|
||||
</div>
|
||||
<div className="pj-col">
|
||||
<div className="pj-col-h">
|
||||
<Truck className="me-1 inline h-3.5 w-3.5 text-green" /> رسیده ({faNum(arrived.length)})
|
||||
</div>
|
||||
{arrived.length ? arrived.map(arrivedRow) : <div className="pj-empty">—</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Navigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Inbox, Plus } from "lucide-react";
|
||||
import type { Supplier } from "../../lib/types";
|
||||
import { nextId } from "../../lib/api";
|
||||
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { SupplierCard, suppliersOfCategory, topicCategories, topicFromParam } from "./supplierUtils";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
// صفحهی تأمینکنندههای یک متریال + فرم افزودن تأمینکننده
|
||||
export function CategoryPage() {
|
||||
useStoreData();
|
||||
const { topic: topicParam = "", category: catParam = "" } = useParams();
|
||||
|
||||
const materials = useMaterials();
|
||||
const suppliers = useSuppliers();
|
||||
const quotes = useQuotes();
|
||||
const addSupplier = useAddSupplier();
|
||||
const removeSupplier = useRemoveSupplier();
|
||||
|
||||
const topic = useMemo(() => topicFromParam(topicParam), [topicParam]);
|
||||
const matched = useMemo(
|
||||
() => (topic ? topicCategories(topic, materials, suppliers).find((c) => normalize(c) === normalize(catParam)) : undefined),
|
||||
[topic, catParam, materials, suppliers],
|
||||
);
|
||||
|
||||
const [form, setForm] = useState<{ name: string; categories: string[]; location: string; phone: string; contact: string }>({
|
||||
name: "",
|
||||
categories: [],
|
||||
location: "",
|
||||
phone: "",
|
||||
contact: "",
|
||||
});
|
||||
|
||||
// همهی تأمینکنندهها برای انتخاب سریع (task: کومبوکس کششده)
|
||||
const supplierOptions = useMemo(
|
||||
() => suppliers.map((s) => ({ id: s.id, name: s.name })),
|
||||
[suppliers],
|
||||
);
|
||||
|
||||
if (!topic || !matched) {
|
||||
return topic ? <Navigate to={`/topics/${encodeURIComponent(topic)}`} replace /> : <Navigate to="/suppliers" replace />;
|
||||
}
|
||||
|
||||
const sups = suppliersOfCategory(topic, matched, suppliers);
|
||||
|
||||
// متریالهای خریداریشده از هر تأمینکننده
|
||||
const supMats = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
quotes.forEach((q) => {
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const m = materials.find((x) => x.id === li.material_id);
|
||||
if (!m) return;
|
||||
const label = `${m.category}${m.brand ? " " + m.brand : ""}${m.specs.length ? " (" + m.specs.join("، ") + ")" : ""}`;
|
||||
if (!map.has(q.supplier_id)) map.set(q.supplier_id, []);
|
||||
const list = map.get(q.supplier_id)!;
|
||||
if (!list.includes(label)) list.push(label);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [quotes, materials]);
|
||||
|
||||
const addSup = () => {
|
||||
const name = form.name.trim();
|
||||
if (!name) {
|
||||
toast.error("نام تأمینکننده را وارد کنید.");
|
||||
return;
|
||||
}
|
||||
// اگر متریالی انتخاب نشده، تأمینکننده به همین متریالِ صفحه اضافه میشود
|
||||
const cats = form.categories.length ? form.categories : [matched];
|
||||
const s: Supplier = {
|
||||
id: nextId("s", suppliers),
|
||||
topic,
|
||||
categories: cats,
|
||||
name,
|
||||
location: form.location.trim(),
|
||||
phone: form.phone.trim(),
|
||||
contact: form.contact.trim(),
|
||||
};
|
||||
void addSupplier.mutateAsync(s);
|
||||
setForm({ name: "", categories: [], location: "", phone: "", contact: "" });
|
||||
toast.success("تأمینکننده افزوده شد.");
|
||||
};
|
||||
|
||||
const removeSup = (id: string) => {
|
||||
const s = suppliers.find((x) => x.id === id);
|
||||
if (!s) return;
|
||||
if (confirm(`تأمینکننده «${s.name}» حذف شود؟ پیشنهادهای قبلی او در دفتر باقی میمانند.`)) {
|
||||
void removeSupplier.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title={matched} sub={`تأمینکنندههای این متریال — ${topic}`} backTo="/suppliers" backLabel="موضوعها" />
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
{!sups.length ? (
|
||||
<EmptyState icon={Inbox} text="هنوز تأمینکنندهای برای این متریال ثبت نشده است." />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{sups.map((s) => (
|
||||
<SupplierCard key={s.id} s={s} topic={topic} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-panel">
|
||||
<h4>
|
||||
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمینکنندهی جدید
|
||||
</h4>
|
||||
<div className="grid max-w-2xl grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label>نام تأمینکننده *</label>
|
||||
<Combobox
|
||||
value={form.name}
|
||||
onChange={(it: { id: string; label: string }) => setForm((f) => ({ ...f, name: it.label }))}
|
||||
items={supplierOptions}
|
||||
allowAdd
|
||||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||||
placeholder="تایپ کنید یا از تأمینکنندهها انتخاب کنید"
|
||||
onInputChange={(v) => setForm((f) => ({ ...f, name: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>متریال (ها)</label>
|
||||
<MultiSelectCombobox
|
||||
values={form.categories}
|
||||
onChange={(cats) => setForm((f) => ({ ...f, categories: cats }))}
|
||||
options={topicCategories(topic, materials, suppliers)}
|
||||
placeholder="متریال را انتخاب یا تایپ کنید..."
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>موقعیت (شهر - منطقه)</label>
|
||||
<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="مثلاً تهران - ظفر"
|
||||
value={form.location}
|
||||
onChange={(e) => setForm((f) => ({ ...f, location: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تلفن</label>
|
||||
<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="۰۲۱-..."
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>شخص رابط</label>
|
||||
<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="نام فرد"
|
||||
value={form.contact}
|
||||
onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" className="mt-3" onClick={addSup}>
|
||||
ذخیره تأمینکننده
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Building2, Paperclip, Plus } from "lucide-react";
|
||||
import type { Attachment, Material, Quote, Supplier } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import { activeItems, materialById, materialSpecs, quoteTotal, specsLabel } from "../../lib/quoteUtils";
|
||||
import { fmtDate, faNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { STATUS_LABEL } from "../../lib/constants";
|
||||
|
||||
interface AttachSet {
|
||||
invoices: Attachment[];
|
||||
proformas: Attachment[];
|
||||
}
|
||||
|
||||
export interface PurchaseHistoryProps {
|
||||
supplier: Supplier;
|
||||
quotes: Quote[];
|
||||
materials: Material[];
|
||||
}
|
||||
|
||||
// سابقه خرید از یک تأمینکننده: همهی پیشنهادها (با بج وضعیت، از جدیدترین)
|
||||
// + فاکتورها و پیشفاکتورها (لینک دانلود از MinIO از طریق api.files)
|
||||
export function PurchaseHistory({ supplier, quotes, materials }: PurchaseHistoryProps) {
|
||||
const purchases = useMemo(() => {
|
||||
return quotes
|
||||
.filter((q) => q.supplier_id === supplier.id)
|
||||
.sort((a, b) => {
|
||||
const ta = a.quote_date ? new Date(a.quote_date).getTime() : 0;
|
||||
const tb = b.quote_date ? new Date(b.quote_date).getTime() : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
}, [quotes, supplier.id]);
|
||||
|
||||
// پیوستها بهصورت موازی برای همهی خریدها خوانده میشوند (الگوی AcceptModal)
|
||||
const [attachments, setAttachments] = useState<Record<string, AttachSet>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!purchases.length) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
void Promise.all(
|
||||
purchases.map(async (q) => {
|
||||
const [invoices, proformas] = await Promise.all([
|
||||
api.files.list(q.id, "invoice").catch(() => [] as Attachment[]),
|
||||
api.files.list(q.id, "proforma").catch(() => [] as Attachment[]),
|
||||
]);
|
||||
return [q.id, { invoices, proformas }] as const;
|
||||
}),
|
||||
)
|
||||
.then((pairs) => {
|
||||
if (!alive) return;
|
||||
const map: Record<string, AttachSet> = {};
|
||||
pairs.forEach(([id, set]) => (map[id] = set));
|
||||
setAttachments(map);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [purchases]);
|
||||
|
||||
const wizardLink = `/wizard?topic=${encodeURIComponent(supplier.topic)}&supplier=${encodeURIComponent(supplier.id)}`;
|
||||
|
||||
return (
|
||||
<div className="ph-card">
|
||||
<div className="ph-head">
|
||||
<span className="ph-title">سابقه خرید از این تأمینکننده</span>
|
||||
{purchases.length > 0 && <span className="ph-count">{faNum(purchases.length)} پیشنهاد</span>}
|
||||
</div>
|
||||
|
||||
{purchases.length === 0 && (
|
||||
<div className="ph-empty">
|
||||
<div className="ph-empty-text">هنوز خریدی از این تأمینکننده ثبت نشده است.</div>
|
||||
<Link to={wizardLink} className="btn-line-file no-underline mt-2 inline-flex bg-accent text-white hover:bg-accent-dark">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
اولین پیشنهاد
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{purchases.map((q) => {
|
||||
const items = activeItems(q).map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
const specs = materialSpecs(m);
|
||||
return `${m.category} ${m.brand}${specs.length ? " " + specsLabel(specs) : ""}`.trim();
|
||||
});
|
||||
const total = quoteTotal(q);
|
||||
const attach = attachments[q.id];
|
||||
const profs = attach?.proformas || [];
|
||||
const invs = attach?.invoices || [];
|
||||
const projects = q.projects && q.projects.length ? q.projects : q.project ? [q.project] : [];
|
||||
return (
|
||||
<div key={q.id} className={`ph-row${q.status === "expired" ? " stale" : ""}`}>
|
||||
<div className="ph-top">
|
||||
<span className="ph-date">{fmtDate(q.quote_date)}</span>
|
||||
<span className={`fb-badge ${q.status}`}>{STATUS_LABEL[q.status] || q.status}</span>
|
||||
<span className="ph-project">
|
||||
<Building2 className="me-1 inline h-3.5 w-3.5 text-muted" />
|
||||
{projects.join("، ") || "بدون پروژه"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ph-items">
|
||||
{items.length ? `${faNum(items.length)} قلم: ${items.join("، ")}` : "بدون قلم"}
|
||||
</div>
|
||||
<div className="ph-total">{groupCommas(total, true)} تومان</div>
|
||||
<div className="ph-attach">
|
||||
{loading && !attach ? (
|
||||
<span className="ph-attach-loading">در حال بارگذاری فاکتورها...</span>
|
||||
) : (
|
||||
<>
|
||||
{profs.map((pf) => (
|
||||
<a
|
||||
key={"pf-" + pf.id}
|
||||
className="detail-link"
|
||||
href={api.files.downloadUrl(q.id, "proforma", pf.id!)}
|
||||
download={pf.name}
|
||||
title="دانلود پیشفاکتور"
|
||||
>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
پیشفاکتور: {pf.name}
|
||||
</a>
|
||||
))}
|
||||
{invs.map((inv) => (
|
||||
<a
|
||||
key={"inv-" + inv.id}
|
||||
className="detail-link"
|
||||
href={api.files.downloadUrl(q.id, "invoice", inv.id!)}
|
||||
download={inv.name}
|
||||
title="دانلود فاکتور"
|
||||
>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
فاکتور: {inv.name}
|
||||
</a>
|
||||
))}
|
||||
{!loading && !profs.length && !invs.length && (
|
||||
<span className="ph-attach-loading">فاکتوری ثبت نشده</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
|
||||
import { topicByName } from "../../lib/constants";
|
||||
import { api } from "../../lib/api";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { PurchaseHistory } from "./PurchaseHistory";
|
||||
|
||||
// صفحهی مشخصات کامل تأمینکننده + سابقه خرید + مدیریت متریالهای تأمینشده
|
||||
export function SupplierDetailPage() {
|
||||
useStoreData();
|
||||
const { id = "" } = useParams();
|
||||
const suppliers = useSuppliers();
|
||||
const quotes = useQuotes();
|
||||
const materials = useMaterials();
|
||||
|
||||
const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]);
|
||||
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
|
||||
// متریالهای متصل واقعی (از اتصال تأمینکننده-متریال بکاند)
|
||||
const [linked, setLinked] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let alive = true;
|
||||
void api.supplierMaterials
|
||||
.list(id)
|
||||
.then((rows) => alive && setLinked(rows.map((r) => r.material_name).filter(Boolean)))
|
||||
.catch(() => alive && setLinked([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
// متریالهای تأمینشده (نام متریالها) برای پیشنهاد در کومبوکس
|
||||
const materialOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
materials.forEach((m) => m.name && set.add(m.name));
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [materials]);
|
||||
|
||||
// متریالهای خریداریشده از این تأمینکننده (از پیشنهادها)
|
||||
const purchased = useMemo(() => {
|
||||
const out = new Set<string>();
|
||||
quotes.forEach((q) => {
|
||||
if (q.supplier_id !== id) return;
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const m = materials.find((x) => x.id === li.material_id);
|
||||
if (!m || !m.category) return;
|
||||
out.add(m.category);
|
||||
});
|
||||
});
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [quotes, materials, id]);
|
||||
|
||||
if (!s) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="اطلاعات تماس تأمینکننده" backTo="/suppliers" backLabel="تأمینکنندهها" />
|
||||
<PageLayout>
|
||||
<EmptyState emoji="😕" text="تأمینکنندهای یافت نشد." />
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const topic = topicByName(s.topic);
|
||||
const topicName = topic ? topic.name : s.topic;
|
||||
const firstCat = (s.categories || [])[0] || "";
|
||||
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
|
||||
|
||||
const saveCategories = (names: string[]) => {
|
||||
// v2.6: اتصال واقعی تأمینکننده ↔ متریال (کاتالوگ) — با نام متریال
|
||||
if (!id || busy) return;
|
||||
setBusy(true);
|
||||
void (async () => {
|
||||
try {
|
||||
for (const name of names) {
|
||||
if (linked.some((x) => normalize(x) === normalize(name))) continue;
|
||||
const mat = materials.find((m) => normalize(m.name) === normalize(name));
|
||||
if (!mat) {
|
||||
toast.error(`متریال «${name}» در کاتالوگ یافت نشد — ابتدا از ویزارد بسازید.`);
|
||||
continue;
|
||||
}
|
||||
await api.supplierMaterials.create(id, mat.id);
|
||||
}
|
||||
const rows = await api.supplierMaterials.list(id);
|
||||
setLinked(rows.map((r) => r.material_name).filter(Boolean));
|
||||
toast.success("متریالها ذخیره شدند.");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "ذخیرهی متریالها ناموفق بود");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const removeCat = (name: string) => {
|
||||
if (!id || busy) return;
|
||||
setBusy(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const mat = materials.find((m) => normalize(m.name) === normalize(name));
|
||||
if (mat) await api.supplierMaterials.remove(id, mat.id);
|
||||
const rows = await api.supplierMaterials.list(id);
|
||||
setLinked(rows.map((r) => r.material_name).filter(Boolean));
|
||||
toast.success("متریال حذف شد.");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "حذف متریال ناموفق بود");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="اطلاعات تماس تأمینکننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمینکنندهها" />
|
||||
<PageLayout>
|
||||
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-xl font-extrabold">{s.name}</div>
|
||||
<Badge variant="outline">{topicName}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
|
||||
<span>
|
||||
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Link
|
||||
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
|
||||
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
پیشنهاد جدید
|
||||
</Link>
|
||||
{firstCat ? (
|
||||
<Link to={`/topics/${encodeURIComponent(topicName)}/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline">
|
||||
‹ بازگشت به دسته
|
||||
</Link>
|
||||
) : (
|
||||
<Link to={`/topics/${encodeURIComponent(topicName)}`} className="btn-line-file no-underline">
|
||||
‹ بازگشت به موضوع
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* متریالهای این تأمینکننده — افزودن/حذف + مشاهدهی همه */}
|
||||
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<h4 className="mb-1 text-[13.5px] font-bold">
|
||||
<Package className="me-1 inline h-4 w-4 text-accent" /> متریالهای تأمینشده
|
||||
</h4>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
متریالهایی که این تأمینکننده عرضه میکند را اینجا مدیریت کنید.
|
||||
</div>
|
||||
{(linked || []).length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{linked.map((c) => (
|
||||
<span key={c} className="spec-chip">
|
||||
{c}
|
||||
<button type="button" className="spec-chip-x" title="حذف متریال" onClick={() => setPendingRemove(c)}>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MultiSelectCombobox
|
||||
values={[]}
|
||||
onChange={(cats) => saveCategories(cats)}
|
||||
options={materialOptions}
|
||||
placeholder="افزودن متریال..."
|
||||
/>
|
||||
{purchased.length > 0 && (
|
||||
<div className="mt-3 border-t border-line pt-2">
|
||||
<div className="mb-1 text-[11px] font-semibold text-muted">متریالهای خریداریشده (از پیشنهادها):</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{purchased.map((c) => (
|
||||
<span key={c} className="spec-chip">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* سابقه خرید از این تأمینکننده — همهی پیشنهادها + فاکتور/پیشفاکتور */}
|
||||
<div className="mt-4 max-w-4xl">
|
||||
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={pendingRemove !== null}
|
||||
itemName={pendingRemove || ""}
|
||||
onConfirm={() => {
|
||||
if (pendingRemove !== null) removeCat(pendingRemove);
|
||||
setPendingRemove(null);
|
||||
}}
|
||||
onCancel={() => setPendingRemove(null)}
|
||||
/>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useMemo } from "react";
|
||||
import { useStoreData, useSuppliers } from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { TOPICS } from "../../lib/constants";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { TopicGrid } from "../../components/TopicGrid";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// صفحهی تأمینکنندهها — انتخاب موضوع
|
||||
export function SuppliersPage() {
|
||||
useStoreData();
|
||||
const suppliers = useSuppliers();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const out: Record<string, number> = {};
|
||||
TOPICS.forEach((t) => {
|
||||
out[t.name] = suppliers.filter((s) => normalize(s.topic) === normalize(t.name)).length;
|
||||
});
|
||||
return out;
|
||||
}, [suppliers]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="تأمینکنندهها"
|
||||
sub="اول موضوع (دستهبندی) را انتخاب کنید"
|
||||
backTo="/"
|
||||
backLabel="بازگشت به دفتر"
|
||||
/>
|
||||
<PageLayout>
|
||||
<TopicGrid
|
||||
onSelect={(topic) => navigate(`/topics/${encodeURIComponent(topic)}`)}
|
||||
showCounts
|
||||
counts={counts}
|
||||
/>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { FolderOpen, Plus } from "lucide-react";
|
||||
import type { Material } from "../../lib/types";
|
||||
import { nextId } from "../../lib/api";
|
||||
import { useAddMaterial, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { SupplierCard, suppliersOfCategory, topicCategories, topicFromParam } from "./supplierUtils";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
// صفحهی متریالهای یک موضوع + تأمینکنندههای بدون متریال + افزودن متریال
|
||||
export function TopicPage() {
|
||||
useStoreData();
|
||||
const { topic: topicParam = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const materials = useMaterials();
|
||||
const suppliers = useSuppliers();
|
||||
const quotes = useQuotes();
|
||||
const addMaterial = useAddMaterial();
|
||||
const removeSupplier = useRemoveSupplier();
|
||||
|
||||
const topic = useMemo(() => topicFromParam(topicParam), [topicParam]);
|
||||
const [newCatName, setNewCatName] = useState("");
|
||||
|
||||
const cats = useMemo(
|
||||
() => (topic ? topicCategories(topic, materials, suppliers) : []),
|
||||
[topic, materials, suppliers],
|
||||
);
|
||||
|
||||
const uncat = useMemo(
|
||||
() =>
|
||||
topic
|
||||
? suppliers.filter((s) => normalize(s.topic) === normalize(topic) && !(s.categories || []).length)
|
||||
: [],
|
||||
[topic, suppliers],
|
||||
);
|
||||
|
||||
if (!topic) return <Navigate to="/suppliers" replace />;
|
||||
|
||||
const addCategory = () => {
|
||||
const name = newCatName.trim();
|
||||
if (!name) return;
|
||||
const dup = materials.some(
|
||||
(m) => normalize(m.topic) === normalize(topic) && normalize(m.category) === normalize(name),
|
||||
);
|
||||
if (dup) {
|
||||
toast.error("این متریال قبلاً ثبت شده است.");
|
||||
return;
|
||||
}
|
||||
const m: Material = {
|
||||
id: nextId("m", materials),
|
||||
name,
|
||||
topic,
|
||||
category: name,
|
||||
brand: "",
|
||||
specs: [],
|
||||
base_unit: "",
|
||||
};
|
||||
void addMaterial.mutateAsync(m);
|
||||
setNewCatName("");
|
||||
toast.success("متریال افزوده شد.");
|
||||
};
|
||||
|
||||
const removeSup = (id: string) => {
|
||||
const s = suppliers.find((x) => x.id === id);
|
||||
if (!s) return;
|
||||
if (confirm(`تأمینکننده «${s.name}» حذف شود؟ پیشنهادهای قبلی او در دفتر باقی میمانند.`)) {
|
||||
void removeSupplier.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
// متریالهای خریداریشده از هر تأمینکننده
|
||||
const supMats = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
quotes.forEach((q) => {
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const m = materials.find((x) => x.id === li.material_id);
|
||||
if (!m) return;
|
||||
const label = `${m.category}${m.brand ? " " + m.brand : ""}${m.specs.length ? " (" + m.specs.join("، ") + ")" : ""}`;
|
||||
if (!map.has(q.supplier_id)) map.set(q.supplier_id, []);
|
||||
const list = map.get(q.supplier_id)!;
|
||||
if (!list.includes(label)) list.push(label);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [quotes, materials]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title={topic} sub="یک متریال را انتخاب کنید" backTo="/suppliers" backLabel="موضوعها" />
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
{cats.length ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{cats.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className="topic-card"
|
||||
onClick={() => navigate(`/topics/${encodeURIComponent(topic)}/${encodeURIComponent(c)}`)}
|
||||
>
|
||||
<div className="t-name" style={{ fontSize: 13 }}>
|
||||
{c}
|
||||
</div>
|
||||
<div className="t-count">{faNum(suppliersOfCategory(topic, c, suppliers).length)} تأمینکننده</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon={FolderOpen} text="هنوز متریالی برای این موضوع ثبت نشده — از پایین یک متریال اضافه کنید." />
|
||||
)}
|
||||
|
||||
{uncat.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-[13.5px] font-bold">تأمینکنندههای این موضوع</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{uncat.map((s) => (
|
||||
<SupplierCard key={s.id} s={s} topic={topic} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-panel">
|
||||
<h4>
|
||||
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن متریال جدید
|
||||
</h4>
|
||||
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
|
||||
<div className="field">
|
||||
<label>نام متریال (مثلاً میلگرد آجدار ۱۴)</label>
|
||||
<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={newCatName}
|
||||
onChange={(e) => setNewCatName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addCategory();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={addCategory}>افزودن متریال</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-muted">
|
||||
بعد از افزودن، داخل همان متریال میتوانید تأمینکننده اضافه یا حذف کنید.
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { MapPin, Phone, User, Plus } from "lucide-react";
|
||||
import type { Material, Supplier } from "../../lib/types";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { topicByName } from "../../lib/constants";
|
||||
|
||||
// دستههای یک تاپیک (اتحاد materials + categories تأمینکنندهها)
|
||||
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
||||
const out = new Set<string>();
|
||||
materials
|
||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
||||
.forEach((m) => out.add(m.category));
|
||||
suppliers
|
||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
|
||||
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
||||
return suppliers.filter(
|
||||
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
||||
);
|
||||
}
|
||||
|
||||
export function topicFromParam(param: string | null): string | null {
|
||||
if (!param) return null;
|
||||
const t = topicByName(param);
|
||||
return t ? t.name : null;
|
||||
}
|
||||
|
||||
// کارت تأمینکننده
|
||||
export function SupplierCard({
|
||||
s,
|
||||
topic,
|
||||
deletable,
|
||||
onDelete,
|
||||
materials = [],
|
||||
}: {
|
||||
s: Supplier;
|
||||
topic: string;
|
||||
deletable?: boolean;
|
||||
onDelete?: (id: string) => void;
|
||||
// متریالهای خریداریشده از این تأمینکننده (بهجای دستهبندی)
|
||||
materials?: string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="sup-card">
|
||||
<div className="sc-head">
|
||||
<Link to={`/suppliers/${s.id}`} className="sc-name-link">
|
||||
{s.name}
|
||||
</Link>
|
||||
{deletable && onDelete && (
|
||||
<button type="button" className="sup-del" title="حذف تأمینکننده" onClick={() => onDelete(s.id)}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="sc-meta">
|
||||
<span>
|
||||
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
|
||||
</span>
|
||||
</div>
|
||||
{materials.length > 0 && (
|
||||
<div className="mt-2 border-t border-line pt-1.5">
|
||||
<div className="mb-1 flex flex-wrap gap-1">
|
||||
{materials.map((m, i) => (
|
||||
<span key={i} className="spec-chip">
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="sc-actions">
|
||||
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
|
||||
<Plus className="h-3 w-3" />
|
||||
پیشنهاد جدید
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Scale, Repeat, TriangleAlert, Link2, Pencil, Trash2, Plus } from "lucide-react";
|
||||
import type { CategoryUnit, StandardConversion, UnitConversion } from "../../lib/types";
|
||||
import { api, nextId } from "../../lib/api";
|
||||
import {
|
||||
useDeleteConversion,
|
||||
useDeleteStandardConversion,
|
||||
useMaterials,
|
||||
useQuotes,
|
||||
useSaveConversion,
|
||||
useSaveRefUnit,
|
||||
useSaveStandardConversion,
|
||||
useStoreData,
|
||||
useSuppliers,
|
||||
useUnits,
|
||||
} from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { TOPICS } from "../../lib/constants";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { parseNum } from "../../lib/numutil";
|
||||
import { findConversion, materialById } from "../../lib/quoteUtils";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { TopicGrid } from "../../components/TopicGrid";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { NumberInput } from "../../components/NumberInput";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
interface RefUnitDraft {
|
||||
id: string;
|
||||
category: string;
|
||||
ref_unit: string;
|
||||
}
|
||||
|
||||
// داشبورد واحدها — دو مرحله: انتخاب تاپیک → تنظیم واحد مرجع و ضریب تبدیل دسته
|
||||
export function UnitsPage() {
|
||||
useStoreData();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const quotes = useQuotes();
|
||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
||||
const saveRefUnit = useSaveRefUnit();
|
||||
const saveConversion = useSaveConversion();
|
||||
const deleteConversion = useDeleteConversion();
|
||||
const saveStd = useSaveStandardConversion();
|
||||
const deleteStd = useDeleteStandardConversion();
|
||||
|
||||
// ---------- state ----------
|
||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [refDraft, setRefDraft] = useState<RefUnitDraft | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingStdId, setEditingStdId] = useState<string | null>(null);
|
||||
|
||||
// فرم ضریب دستهای
|
||||
const [formFrom, setFormFrom] = useState("");
|
||||
const [formFactor, setFormFactor] = useState("");
|
||||
const [formBrand, setFormBrand] = useState("");
|
||||
|
||||
// فرم تبدیل ثابت
|
||||
const [stdFrom, setStdFrom] = useState("");
|
||||
const [stdFactor, setStdFactor] = useState("");
|
||||
const [stdTo, setStdTo] = useState("");
|
||||
|
||||
// ---------- مشتقات ----------
|
||||
const catTopicMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
materials.forEach((m) => {
|
||||
if (m.category && m.topic) map[m.category] = m.topic;
|
||||
});
|
||||
suppliers.forEach((s) => {
|
||||
(s.categories || []).forEach((c) => {
|
||||
if (c && !map[c]) map[c] = s.topic;
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [materials, suppliers]);
|
||||
|
||||
const categoriesOfTopic = useMemo(() => {
|
||||
if (!selectedTopic) return [];
|
||||
return Object.keys(catTopicMap)
|
||||
.filter((c) => normalize(catTopicMap[c]) === normalize(selectedTopic))
|
||||
.sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [catTopicMap, selectedTopic]);
|
||||
|
||||
const refUnitFor = (cat: string): string =>
|
||||
categoryUnits.find((c) => normalize(c.category) === normalize(cat))?.ref_unit || "";
|
||||
|
||||
const conversionsFor = (cat: string): UnitConversion[] =>
|
||||
unitConversions
|
||||
.filter((c) => normalize(c.category) === normalize(cat))
|
||||
.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0) || a.from_unit.localeCompare(b.from_unit, "fa"));
|
||||
|
||||
// واحدهای استفادهشده در quoteهای دسته که ضریب ندارند
|
||||
const unknownUnitsFor = (cat: string): { unit: string; count: number }[] => {
|
||||
const ref = refUnitFor(cat);
|
||||
const out = new Map<string, number>();
|
||||
quotes.forEach((q) =>
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected || !li.unit) return;
|
||||
const m = materialById(materials, li.material_id);
|
||||
if (m.category === "؟" || normalize(m.category) !== normalize(cat)) return;
|
||||
if (ref && normalize(li.unit) === normalize(ref)) return;
|
||||
if (ref && findConversion({ unitConversions, standardConversions }, cat, m.brand, li.unit, ref)) return;
|
||||
out.set(li.unit, (out.get(li.unit) || 0) + 1);
|
||||
}),
|
||||
);
|
||||
return [...out.entries()].map(([unit, count]) => ({ unit, count })).sort((a, b) => a.unit.localeCompare(b.unit, "fa"));
|
||||
};
|
||||
|
||||
const suggestedUnitsFor = (cat: string): string[] => {
|
||||
const out = new Set<string>();
|
||||
quotes.forEach((q) =>
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected || !li.unit) return;
|
||||
const m = materialById(materials, li.material_id);
|
||||
if (m.category !== "؟" && normalize(m.category) === normalize(cat)) out.add(li.unit);
|
||||
}),
|
||||
);
|
||||
conversionsFor(cat).forEach((c) => out.add(c.from_unit));
|
||||
standardConversions.forEach((c) => {
|
||||
out.add(c.from_unit);
|
||||
out.add(c.to_unit);
|
||||
});
|
||||
return [...out].filter(Boolean).sort((a, b) => a.localeCompare(b, "fa"));
|
||||
};
|
||||
|
||||
// برندهای پیشنهادی — همهی برندها از همهی متریالها (مثل «ثبت پیشنهاد جدید») +
|
||||
// برندهای ثبتشده در بکاند + برندهای ضرایب
|
||||
const suggestedBrandsFor = (): string[] => {
|
||||
const out = new Set<string>();
|
||||
materials
|
||||
.filter((m) => m.brand)
|
||||
.forEach((m) => out.add(m.brand));
|
||||
unitConversions.forEach((c) => c.brand && out.add(c.brand));
|
||||
allBrands.forEach((b) => out.add(b));
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
};
|
||||
|
||||
const [allBrands, setAllBrands] = useState<string[]>([]);
|
||||
// همهی برندها از بکاند
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void api
|
||||
.brandsList()
|
||||
.then((list) => alive && setAllBrands(list.map((b) => b.name)))
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const topicCounts = useMemo(() => {
|
||||
const out: Record<string, number> = {};
|
||||
TOPICS.forEach((t) => {
|
||||
out[t.name] = Object.keys(catTopicMap).filter((c) => normalize(catTopicMap[c]) === normalize(t.name)).length;
|
||||
});
|
||||
return out;
|
||||
}, [catTopicMap]);
|
||||
|
||||
// ---------- actions ----------
|
||||
const saveRef = () => {
|
||||
if (!selectedCategory) return;
|
||||
const val = refDraft?.ref_unit.trim() || "";
|
||||
if (!val) return;
|
||||
const cu: CategoryUnit = refDraft?.id
|
||||
? { id: refDraft.id, category: selectedCategory, ref_unit: val }
|
||||
: { id: nextId("cu", categoryUnits), category: selectedCategory, ref_unit: val };
|
||||
void saveRefUnit
|
||||
.mutateAsync(cu)
|
||||
.catch((err) => toast.error(err instanceof Error ? err.message : "ذخیرهی واحد مرجع ناموفق بود"));
|
||||
};
|
||||
|
||||
const factorNum = (): number | null => {
|
||||
const n = parseNum(formFactor);
|
||||
return n != null && !Number.isNaN(n) && n > 0 ? n : null;
|
||||
};
|
||||
|
||||
const convLive = useMemo(() => {
|
||||
if (!selectedCategory) return null;
|
||||
const ref = refUnitFor(selectedCategory);
|
||||
const from = formFrom.trim();
|
||||
if (!from && !formFactor.trim() && !formBrand.trim())
|
||||
return 'مثلاً: واحد پیشنهادشده «کیسه»، ضریب «۵۰» و مرجع «کیلوگرم» → یعنی هر کیسه = ۵۰ کیلوگرم';
|
||||
if (!from) return "واحد پیشنهادشده را بنویسید (مثلاً کیسه).";
|
||||
if (!ref) return "⚠️ اول «واحد مرجع» این دسته را در بالا مشخص کنید.";
|
||||
const f = factorNum();
|
||||
if (f == null) return "ضریب را وارد کنید (مثلاً ۵۰) تا معنی تبدیل مشخص شود.";
|
||||
const brandPart = formBrand.trim() ? ` از برند «${formBrand.trim()}»` : "";
|
||||
return `✅ یعنی هر ${from}${brandPart} برابر ${faNum(f)} ${ref} است.`;
|
||||
}, [formFrom, formFactor, formBrand, selectedCategory, categoryUnits]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const saveConv = () => {
|
||||
if (!selectedCategory) return;
|
||||
const from = formFrom.trim();
|
||||
if (!from) return;
|
||||
const to = refUnitFor(selectedCategory);
|
||||
if (!to) {
|
||||
toast.error("اول واحد مرجع این دسته را مشخص کنید.");
|
||||
return;
|
||||
}
|
||||
const factor = factorNum();
|
||||
if (factor == null) return;
|
||||
const onError = (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : "ذخیرهی ضریب تبدیل ناموفق بود");
|
||||
};
|
||||
if (editingId) {
|
||||
const c: UnitConversion = {
|
||||
id: editingId,
|
||||
category: selectedCategory,
|
||||
brand: formBrand.trim(),
|
||||
from_unit: from,
|
||||
to_unit: to,
|
||||
factor,
|
||||
};
|
||||
void saveConversion.mutateAsync(c).catch(onError);
|
||||
} else {
|
||||
void saveConversion
|
||||
.mutateAsync({
|
||||
id: nextId("uc", unitConversions),
|
||||
category: selectedCategory,
|
||||
brand: formBrand.trim(),
|
||||
from_unit: from,
|
||||
to_unit: to,
|
||||
factor,
|
||||
})
|
||||
.catch(onError);
|
||||
}
|
||||
setEditingId(null);
|
||||
setFormFrom("");
|
||||
setFormFactor("");
|
||||
setFormBrand("");
|
||||
};
|
||||
|
||||
const startEdit = (id: string) => {
|
||||
const c = unitConversions.find((x) => x.id === id);
|
||||
if (!c) return;
|
||||
setEditingId(id);
|
||||
setFormFrom(c.from_unit);
|
||||
setFormFactor(String(c.factor));
|
||||
setFormBrand(c.brand || "");
|
||||
};
|
||||
|
||||
const saveStdConv = () => {
|
||||
const from = stdFrom.trim();
|
||||
const to = stdTo.trim();
|
||||
if (!from || !to) return;
|
||||
const n = parseNum(stdFactor);
|
||||
if (n == null || Number.isNaN(n) || n <= 0) return;
|
||||
if (editingStdId) {
|
||||
const c: StandardConversion = { id: editingStdId, from_unit: from, to_unit: to, factor: n };
|
||||
void saveStd.mutateAsync(c).catch((err) =>
|
||||
toast.error(err instanceof Error ? err.message : "ذخیرهی تبدیل ثابت ناموفق بود"),
|
||||
);
|
||||
} else {
|
||||
void saveStd
|
||||
.mutateAsync({ id: nextId("sc", standardConversions), from_unit: from, to_unit: to, factor: n })
|
||||
.catch((err) => toast.error(err instanceof Error ? err.message : "ذخیرهی تبدیل ثابت ناموفق بود"));
|
||||
}
|
||||
setEditingStdId(null);
|
||||
setStdFrom("");
|
||||
setStdFactor("");
|
||||
setStdTo("");
|
||||
};
|
||||
|
||||
const startStdEdit = (id: string) => {
|
||||
const c = standardConversions.find((x) => x.id === id);
|
||||
if (!c) return;
|
||||
setEditingStdId(id);
|
||||
setStdFrom(c.from_unit);
|
||||
setStdFactor(String(c.factor));
|
||||
setStdTo(c.to_unit);
|
||||
};
|
||||
|
||||
const backToTopics = () => {
|
||||
setSelectedTopic(null);
|
||||
setSelectedCategory(null);
|
||||
setEditingId(null);
|
||||
setEditingStdId(null);
|
||||
};
|
||||
|
||||
// ---------- پیشفرض از URL ----------
|
||||
useMemo(() => {
|
||||
const cat = params.get("cat");
|
||||
const topicParam = params.get("topic");
|
||||
const unitParam = params.get("unit");
|
||||
let catFound: string | null = null;
|
||||
if (cat) {
|
||||
catFound = Object.keys(catTopicMap).find((c) => normalize(c) === normalize(cat)) || null;
|
||||
if (catFound) {
|
||||
setSelectedTopic(catTopicMap[catFound]);
|
||||
setSelectedCategory(catFound);
|
||||
}
|
||||
}
|
||||
if (!catFound && topicParam) {
|
||||
const t = TOPICS.find((x) => normalize(x.name) === normalize(topicParam));
|
||||
if (t) setSelectedTopic(t.name);
|
||||
}
|
||||
if (unitParam && catFound) {
|
||||
setFormFrom(unitParam);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ---------- render ----------
|
||||
const ref = selectedCategory ? refUnitFor(selectedCategory) : "";
|
||||
const conversions = selectedCategory ? conversionsFor(selectedCategory) : [];
|
||||
const unknownUnits = selectedCategory ? unknownUnitsFor(selectedCategory) : [];
|
||||
|
||||
// گروهبندی بر اساس from_unit
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, UnitConversion[]>();
|
||||
conversions.forEach((c) => {
|
||||
const key = c.from_unit;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(c);
|
||||
});
|
||||
return [...map.entries()];
|
||||
}, [conversions]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="ضرایب تبدیل" sub="اول موضوع، بعد دسته — واحد مرجع و ضریب تبدیل همان دسته را تنظیم کنید" backTo="/" backLabel="بازگشت به دفتر" />
|
||||
<PageLayout>
|
||||
{!selectedTopic ? (
|
||||
<div>
|
||||
<div className="units-hint mb-3 text-[12px] text-muted">
|
||||
اول موضوع را انتخاب کنید تا فقط دستههای همان موضوع بیاید
|
||||
</div>
|
||||
<TopicGrid onSelect={(t) => setSelectedTopic(t)} showCounts counts={topicCounts} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="units-layout">
|
||||
{/* درخت دستهها */}
|
||||
<aside className="units-tree">
|
||||
<div className="tree-head">
|
||||
<button type="button" className="btn-line-file" onClick={backToTopics}>
|
||||
‹ موضوعها
|
||||
</button>
|
||||
<span className="text-[12px]">
|
||||
دستههای <b>{selectedTopic}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{categoriesOfTopic.length ? (
|
||||
categoriesOfTopic.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={cn("ut-cat", selectedCategory === c && "selected")}
|
||||
onClick={() => {
|
||||
setSelectedCategory(c);
|
||||
setEditingId(null);
|
||||
setRefDraft(categoryUnits.find((x) => normalize(x.category) === normalize(c)) || null);
|
||||
}}
|
||||
>
|
||||
<span>{c}</span>
|
||||
<span className="ut-ref">{refUnitFor(c) || "—"}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[12px] text-muted">دستهای در این موضوع ثبت نشده است.</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* پنل اصلی */}
|
||||
<section className="units-panel">
|
||||
{!selectedCategory ? (
|
||||
<div className="units-empty py-10 text-center text-sm text-muted">
|
||||
یک دسته را از سمت راست انتخاب کنید
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* واحد مرجع */}
|
||||
<div className="ref-unit-box">
|
||||
<div className="ref-title">
|
||||
<Scale className="me-1 inline h-4 w-4 text-accent" /> واحد مرجع این دسته
|
||||
</div>
|
||||
<div className="ref-hint">همهی پیشنهادهای این دسته برای مقایسه به این واحد تبدیل میشوند</div>
|
||||
<div className="ref-controls">
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-48 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={refDraft?.ref_unit ?? ref}
|
||||
onChange={(e) => setRefDraft((d) => ({ id: d?.id || "", category: selectedCategory, ref_unit: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveRef();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" onClick={saveRef}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ضرایب دسته */}
|
||||
<h4 className="sec-title">
|
||||
<Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریبهای تبدیل
|
||||
</h4>
|
||||
{!grouped.length ? (
|
||||
<div className="conv-empty">هنوز ضریبی برای این دسته ثبت نشده است.</div>
|
||||
) : (
|
||||
grouped.map(([fromUnit, rows]) => (
|
||||
<div key={fromUnit}>
|
||||
<div className="cg-head">
|
||||
{fromUnit} ← {rows[0].to_unit || ref}
|
||||
</div>
|
||||
{rows.map((c) => (
|
||||
<div key={c.id} className="conv-row">
|
||||
<span className="cv-brand">{c.brand ? `برند: ${c.brand}` : "همه برندها"}</span>
|
||||
<span className="cv-factor">{faNum(c.factor)}</span>
|
||||
<span className="cv-arrow">×</span>
|
||||
<span className="cv-unit">{c.to_unit || ref}</span>
|
||||
<span className="conv-actions">
|
||||
<button type="button" title="ویرایش" onClick={() => startEdit(c.id)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="حذف"
|
||||
onClick={() => void deleteConversion.mutateAsync(c.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* فرم افزودن ضریب */}
|
||||
<div className="add-conv">
|
||||
<h4 className="mb-2 text-[13.5px] font-bold">
|
||||
{editingId ? (
|
||||
<>
|
||||
<Pencil className="me-1 inline h-3.5 w-3.5" /> ویرایش ضریب تبدیل
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="me-1 inline h-3.5 w-3.5" /> افزودن ضریب تبدیل
|
||||
</>
|
||||
)}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1.2fr_0.6fr_1fr_0.8fr_auto] sm:items-end">
|
||||
<div className="field">
|
||||
<label>واحد پیشنهادشده</label>
|
||||
<Combobox
|
||||
value={formFrom}
|
||||
onChange={(it: { id: string; label: string }) => setFormFrom(it.label)}
|
||||
items={suggestedUnitsFor(selectedCategory)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان واحد جدید"
|
||||
placeholder="مثلاً حلقه"
|
||||
onInputChange={(v) => setFormFrom(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>ضریب</label>
|
||||
<NumberInput
|
||||
value={factorNum()}
|
||||
onChange={(n) => setFormFactor(n != null ? String(n) : "")}
|
||||
placeholder="مثلاً 0.98"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>برند (خالی = همه برندها)</label>
|
||||
<Combobox
|
||||
value={formBrand}
|
||||
onChange={(it: { id: string; label: string }) => setFormBrand(it.label)}
|
||||
items={suggestedBrandsFor()}
|
||||
allowAdd
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="مثلاً سیمکو"
|
||||
onInputChange={(v) => setFormBrand(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>به واحد (مرجع)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-slate-bg px-3 text-sm text-muted"
|
||||
value={ref || "مرجع"}
|
||||
placeholder="مرجع"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" onClick={saveConv}>
|
||||
{editingId ? "ذخیره تغییرات" : "افزودن"}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="slate"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setFormFrom("");
|
||||
setFormFactor("");
|
||||
setFormBrand("");
|
||||
}}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="conv-live">{convLive}</div>
|
||||
</div>
|
||||
|
||||
{/* واحدهای ناشناخته */}
|
||||
{unknownUnits.length > 0 && (
|
||||
<>
|
||||
<h4 className="sec-title">
|
||||
<TriangleAlert className="me-1 inline h-4 w-4 text-amber" /> واحدهای استفادهشده بدون ضریب
|
||||
</h4>
|
||||
{unknownUnits.map((u) => (
|
||||
<div key={u.unit} className="unknown-row">
|
||||
<span className="uk-unit">⚠️ {u.unit}</span>
|
||||
<span className="uk-count">{faNum(u.count)} پیشنهاد بدون ضریب</span>
|
||||
<span className="ms-auto">
|
||||
<Button
|
||||
variant="line"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setFormFrom(u.unit);
|
||||
setFormFactor("");
|
||||
setFormBrand("");
|
||||
}}
|
||||
>
|
||||
+ افزودن ضریب
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* تبدیلهای ثابت */}
|
||||
<h4 className="sec-title">
|
||||
<Link2 className="me-1 inline h-4 w-4 text-accent" /> تبدیلهای ثابت (همهی دستهها)
|
||||
</h4>
|
||||
{!standardConversions.length ? (
|
||||
<div className="conv-empty">تبدیل ثابتی ثبت نشده است.</div>
|
||||
) : (
|
||||
standardConversions.map((c) => (
|
||||
<div key={c.id} className="conv-row">
|
||||
<span className="cv-unit">{c.from_unit}</span>
|
||||
<span className="cv-arrow">←</span>
|
||||
<span className="cv-factor">{faNum(c.factor)}</span>
|
||||
<span className="cv-arrow">←</span>
|
||||
<span className="cv-unit">{c.to_unit}</span>
|
||||
<span className="cv-brand">ثابت</span>
|
||||
<span className="conv-actions">
|
||||
<button type="button" title="ویرایش" onClick={() => startStdEdit(c.id)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="حذف"
|
||||
onClick={() => void deleteStd.mutateAsync(c.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* فرم تبدیل ثابت */}
|
||||
<div className="add-conv">
|
||||
<h4 className="mb-2 text-[13.5px] font-bold">
|
||||
{editingStdId ? (
|
||||
<>
|
||||
<Pencil className="me-1 inline h-3.5 w-3.5" /> ویرایش تبدیل ثابت
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="me-1 inline h-3.5 w-3.5" /> افزودن تبدیل ثابت
|
||||
</>
|
||||
)}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_0.8fr_1fr_auto] sm:items-end">
|
||||
<div className="field">
|
||||
<label>از واحد</label>
|
||||
<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={stdFrom}
|
||||
onChange={(e) => setStdFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>ضریب</label>
|
||||
<NumberInput
|
||||
value={parseNum(stdFactor)}
|
||||
onChange={(n) => setStdFactor(n != null ? String(n) : "")}
|
||||
placeholder="مثلاً 1000"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>به واحد</label>
|
||||
<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={stdTo}
|
||||
onChange={(e) => setStdTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" onClick={saveStdConv}>
|
||||
{editingStdId ? "ذخیره تغییرات" : "افزودن"}
|
||||
</Button>
|
||||
{editingStdId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="slate"
|
||||
onClick={() => {
|
||||
setEditingStdId(null);
|
||||
setStdFrom("");
|
||||
setStdFactor("");
|
||||
setStdTo("");
|
||||
}}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="conv-hint">
|
||||
تبدیل ثابت برای همهی دستهها اعمال میشود؛ «عدد» واحد مرجع خانوادهی عددی است. ضریب یعنی: ۱ از واحد = ضریب × به واحد
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Material, Supplier } from "../../lib/types";
|
||||
import { TOPICS } from "../../lib/constants";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { useSuppliers } from "../../hooks";
|
||||
import { TopicGrid } from "../../components/TopicGrid";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface Step1Props {
|
||||
selectedTopic: string;
|
||||
onTopicSelect: (topic: string) => void;
|
||||
supplierName: string;
|
||||
onSupplierName: (name: string) => void;
|
||||
newSup: { location: string; phone: string; contact: string };
|
||||
onNewSup: (patch: Partial<{ location: string; phone: string; contact: string }>) => void;
|
||||
isOfficial: boolean;
|
||||
onOfficial: (v: boolean) => void;
|
||||
invalid: boolean;
|
||||
}
|
||||
|
||||
// مرحله ۱ — تاپیک + تأمینکننده (چیپهای سریع + فرم تأمینکنندهی جدید)
|
||||
export function Step1TopicSupplier({
|
||||
selectedTopic,
|
||||
onTopicSelect,
|
||||
supplierName,
|
||||
onSupplierName,
|
||||
newSup,
|
||||
onNewSup,
|
||||
isOfficial,
|
||||
onOfficial,
|
||||
invalid,
|
||||
}: Step1Props) {
|
||||
const suppliers = useSuppliers();
|
||||
const [selectedSupId, setSelectedSupId] = useState<string | null>(null);
|
||||
|
||||
const topicSups = useMemo(
|
||||
() => (selectedTopic ? suppliers.filter((s) => normalize(s.topic) === normalize(selectedTopic)) : []),
|
||||
[suppliers, selectedTopic],
|
||||
);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const out: Record<string, number> = {};
|
||||
TOPICS.forEach((t) => {
|
||||
out[t.name] = suppliers.filter((s) => normalize(s.topic) === normalize(t.name)).length;
|
||||
});
|
||||
return out;
|
||||
}, [suppliers]);
|
||||
|
||||
// آیا تأمینکنندهی فعلی در لیست هست؟
|
||||
const matched = useMemo(() => {
|
||||
const n = normalize(supplierName);
|
||||
if (!n) return null;
|
||||
return topicSups.find((s) => normalize(s.name) === n) || null;
|
||||
}, [topicSups, supplierName]);
|
||||
|
||||
const showNewSupForm = supplierName.trim().length > 0 && !matched;
|
||||
|
||||
const pickSupplier = (s: Supplier) => {
|
||||
onSupplierName(s.name);
|
||||
setSelectedSupId(s.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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">
|
||||
اول موضوع کالای موردنیاز را از بین تصاویر انتخاب کنید. تأمینکنندهها بر اساس موضوع فیلتر میشوند.
|
||||
</div>
|
||||
<TopicGrid
|
||||
selectedTopic={selectedTopic}
|
||||
onSelect={onTopicSelect}
|
||||
showCounts
|
||||
counts={counts}
|
||||
/>
|
||||
|
||||
{selectedTopic && (
|
||||
<div className="mt-5">
|
||||
<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-2 flex flex-wrap gap-1.5">
|
||||
{!topicSups.length ? (
|
||||
<div className="chip-divider">
|
||||
هنوز تأمینکنندهای در این موضوع ثبت نشده — پایین را تایپ کنید تا بهعنوان تأمینکنندهی جدید ذخیره شود.
|
||||
</div>
|
||||
) : (
|
||||
topicSups.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={cn("quick-chip", selectedSupId === s.id && "selected")}
|
||||
onClick={() => pickSupplier(s)}
|
||||
>
|
||||
{s.name} <span className="qc-sub">{s.location || ""}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("field max-w-md", invalid && "invalid")}>
|
||||
<label>یا نام تأمینکننده را تایپ و انتخاب کنید</label>
|
||||
<Combobox
|
||||
value={supplierName}
|
||||
onChange={(it: { id: string; label: string }) => {
|
||||
onSupplierName(it.label);
|
||||
setSelectedSupId(it.id === it.label ? null : it.id);
|
||||
}}
|
||||
items={topicSups.map((s) => ({ id: s.id, name: s.name }))}
|
||||
allowAdd
|
||||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||||
placeholder="تایپ کنید... مثلاً زاگرس"
|
||||
onInputChange={(v) => {
|
||||
onSupplierName(v);
|
||||
setSelectedSupId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showNewSupForm && (
|
||||
<div className="mt-3 rounded-xl border border-dashed border-accent/40 bg-blue-bg/20 p-3">
|
||||
<div className="mb-2 text-[12px] text-muted">
|
||||
این تأمینکننده در لیست نیست. جزئیات را تکمیل کنید تا ذخیره شود:
|
||||
</div>
|
||||
<div className="grid max-w-2xl grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label>موقعیت (شهر - منطقه)</label>
|
||||
<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="مثلاً اصفهان - ذوب آهن"
|
||||
value={newSup.location}
|
||||
onChange={(e) => onNewSup({ location: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تلفن</label>
|
||||
<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="۰۲۱-..."
|
||||
value={newSup.phone}
|
||||
onChange={(e) => onNewSup({ phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>شخص رابط</label>
|
||||
<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="نام فرد"
|
||||
value={newSup.contact}
|
||||
onChange={(e) => onNewSup({ contact: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supplierName.trim() && (
|
||||
<label className="mt-3 flex cursor-pointer items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={isOfficial}
|
||||
onChange={(e) => onOfficial(e.target.checked)}
|
||||
/>
|
||||
فاکتور رسمی
|
||||
<span className="text-[11px] text-muted">
|
||||
(تأمینکننده برای این معامله فاکتور رسمی صادر میکند — نه فقط توافق تلفنی)
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// دستههای موجود داخل یک تاپیک (از materials + تأمینکنندهها)
|
||||
export function topicCategories(topic: string, suppliers: Supplier[], materials: Material[]): string[] {
|
||||
const out = new Set<string>();
|
||||
materials
|
||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
||||
.forEach((m) => out.add(m.category));
|
||||
suppliers
|
||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Material } from "../../lib/types";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { useMaterials } from "../../hooks";
|
||||
import { specsKey } from "../../lib/quoteUtils";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { NumberInput } from "../../components/NumberInput";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
export interface ItemDraft {
|
||||
tempId: string;
|
||||
cat: string;
|
||||
brand: string;
|
||||
specs: string[];
|
||||
qty: number | null;
|
||||
unit: string;
|
||||
price: number | null;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
export interface MaterialRec {
|
||||
topic: string;
|
||||
category: string;
|
||||
brand: string;
|
||||
specs: string[];
|
||||
}
|
||||
|
||||
export interface Step2Props {
|
||||
items: ItemDraft[];
|
||||
onItemsChange: (items: ItemDraft[]) => void;
|
||||
selectedTopic: string;
|
||||
onRemember: (rec: MaterialRec) => void;
|
||||
}
|
||||
|
||||
// برچسب انسانی یک قلم (برای termsheet، درگاندروپ و بازبینی)
|
||||
export function itemRowLabel(it: ItemDraft): string {
|
||||
const parts = [it.cat, it.brand, it.specs.join(" - ")].filter(Boolean);
|
||||
const name = parts.length ? parts.join(" - ") : "(قلم بدوننام)";
|
||||
const qtyPart = it.qty != null && it.unit ? `${it.qty} ${it.unit}` : it.qty != null ? String(it.qty) : "";
|
||||
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
||||
}
|
||||
|
||||
// مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پلهبهپله (تاپیک → دسته → برند)
|
||||
// + مقادیر تایپشده در همهی سطرهای قبلی (suggestion از همهی فیلدها)
|
||||
// نکته: برند و واحد از همهی دستهها نمایش داده میشوند (issue #7)
|
||||
export function materialOptions(
|
||||
materials: Material[],
|
||||
topic: string,
|
||||
item: ItemDraft,
|
||||
field: "cat" | "brand" | "spec",
|
||||
allItems: ItemDraft[] = [],
|
||||
apiBrands: string[] = [],
|
||||
): string[] {
|
||||
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
|
||||
const cat = item.cat.trim();
|
||||
const brand = item.brand.trim();
|
||||
|
||||
// مقادیر واردشده در سطرهای دیگر — برای پیشنهاد در سطرهای جدید
|
||||
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
||||
const out = new Set<string>();
|
||||
allItems.forEach((it) => {
|
||||
if (it.tempId === item.tempId) return;
|
||||
const v = sel(it);
|
||||
if (Array.isArray(v)) v.forEach((x) => x && out.add(x));
|
||||
else if (v) out.add(v);
|
||||
});
|
||||
return [...out];
|
||||
};
|
||||
|
||||
if (field === "cat") {
|
||||
const base = new Set(list.map((m) => m.category).filter(Boolean));
|
||||
fromRows((it) => it.cat).forEach((c) => base.add(c));
|
||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
|
||||
if (field === "brand") {
|
||||
// همهی برندها از همهی materials + برندهای ثبتشده در بخش برندها (بکاند)
|
||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
if (brand) list = list.filter((m) => normalize(m.brand) === normalize(brand));
|
||||
const base = new Set(list.flatMap((m) => (m.specs || []).filter(Boolean)));
|
||||
fromRows((it) => it.specs).forEach((s) => base.add(s));
|
||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
|
||||
// مرحله ۲ — جدول پویای اقلام سفارش
|
||||
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
||||
const materials = useMaterials();
|
||||
|
||||
// واحدها از بکاند خوانده میشوند + واحدهای واردشده در سطرهای قبلی
|
||||
const [unitOptions, setUnitOptions] = useState<string[]>(() => {
|
||||
const all = new Set<string>();
|
||||
items.forEach((it) => { if (it.unit) all.add(it.unit); });
|
||||
return [...all];
|
||||
});
|
||||
// واحدهای تایپشده در سطرهای قبلی
|
||||
const allUnits = useMemo(() => {
|
||||
const out = new Set(unitOptions);
|
||||
items.forEach((it) => { if (it.unit) out.add(it.unit); });
|
||||
return [...out];
|
||||
}, [items, unitOptions]);
|
||||
|
||||
// برندهای ثبتشده در بخش برندها (بکاند) — بهعلاوهی برندهای متریالها
|
||||
const [brandOptions, setBrandOptions] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void api
|
||||
.unitsList()
|
||||
.then((list) => alive && setUnitOptions((prev) => {
|
||||
const merged = new Set(prev);
|
||||
list.forEach((u) => { if (u.name_fa || u.code) merged.add(u.name_fa || u.code); });
|
||||
return [...merged];
|
||||
}))
|
||||
.catch(() => { /* بکاند در دسترس نیست */ });
|
||||
void api
|
||||
.brandsList()
|
||||
.then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean)))
|
||||
.catch(() => { /* بکاند در دسترس نیست */ });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const patch = (tempId: string, p: Partial<ItemDraft>) => {
|
||||
onItemsChange(items.map((it) => (it.tempId === tempId ? { ...it, ...p } : it)));
|
||||
};
|
||||
|
||||
const remove = (tempId: string) => {
|
||||
onItemsChange(items.filter((it) => it.tempId !== tempId));
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
// سطر جدید — مقدارها خالی؛ پیشنهادها از همهی سطرهای قبلی میآیند
|
||||
onItemsChange([
|
||||
...items,
|
||||
{ tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6), cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null },
|
||||
]);
|
||||
};
|
||||
|
||||
// یادآوری یک فیلد تایپشده برای پیشنهادهای آینده (داخل تاپیک)
|
||||
const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => {
|
||||
if (!selectedTopic) return;
|
||||
let rec: MaterialRec;
|
||||
if (field === "i-cat") {
|
||||
if (!it.cat.trim()) return;
|
||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: "", specs: [] };
|
||||
} else if (field === "i-brand") {
|
||||
if (!it.brand.trim()) return;
|
||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: [] };
|
||||
} else {
|
||||
if (!it.specs.length) return;
|
||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
||||
}
|
||||
const dup = materials.some(
|
||||
(m) =>
|
||||
normalize(m.topic) === normalize(rec.topic) &&
|
||||
normalize(m.category) === normalize(rec.category) &&
|
||||
normalize(m.brand) === normalize(rec.brand) &&
|
||||
specsKey(m.specs) === specsKey(rec.specs),
|
||||
);
|
||||
if (dup) return;
|
||||
onRemember(rec);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
||||
|
||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
||||
<span>متریال</span>
|
||||
<span>برند</span>
|
||||
<span>مشخصه</span>
|
||||
<span>تعداد</span>
|
||||
<span>واحد</span>
|
||||
<span>قیمت واحد</span>
|
||||
<span>قیمت کل</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((it) => (
|
||||
<div key={it.tempId} className="item-row wizard-item-row">
|
||||
<Combobox
|
||||
value={it.cat}
|
||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||
items={materialOptions(materials, selectedTopic, it, "cat", items)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان متریال جدید"
|
||||
placeholder="متریال"
|
||||
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
||||
onBlur={() => rememberField("i-cat", it)}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<Combobox
|
||||
value={it.brand}
|
||||
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
||||
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="برند"
|
||||
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
||||
onBlur={() => rememberField("i-brand", it)}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<MultiSelectCombobox
|
||||
values={it.specs}
|
||||
onChange={(specs) => patch(it.tempId, { specs })}
|
||||
options={materialOptions(materials, selectedTopic, it, "spec", items)}
|
||||
placeholder="مشخصه"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<NumberInput
|
||||
value={it.qty}
|
||||
onChange={(qty) => {
|
||||
patch(it.tempId, {
|
||||
qty,
|
||||
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="تعداد"
|
||||
showWords
|
||||
/>
|
||||
<Combobox
|
||||
value={it.unit}
|
||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||
items={allUnits}
|
||||
allowAdd
|
||||
addSuffix="اضافه کردن واحد جدید"
|
||||
placeholder="واحد"
|
||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<NumberInput
|
||||
value={it.price}
|
||||
onChange={(price) => {
|
||||
patch(it.tempId, {
|
||||
price,
|
||||
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت واحد"
|
||||
showWords
|
||||
maxDecimals={2}
|
||||
/>
|
||||
<NumberInput
|
||||
value={it.total}
|
||||
onChange={(total) => {
|
||||
patch(it.tempId, {
|
||||
total,
|
||||
price:
|
||||
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت کل"
|
||||
showWords
|
||||
/>
|
||||
<button type="button" className="cursor-pointer text-muted hover:text-red" title="حذف" onClick={() => remove(it.tempId)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className="btn-line-file mt-2.5" onClick={add}>
|
||||
+ افزودن قلم
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Milestone } from "../../lib/types";
|
||||
import { TermsheetCard } from "../../components/TermsheetCard";
|
||||
import { itemRowLabel, type ItemDraft } from "./Step2LineItems";
|
||||
|
||||
export interface TsCardDraft {
|
||||
key: string;
|
||||
milestones: Milestone[];
|
||||
}
|
||||
|
||||
export interface Step3Props {
|
||||
cards: TsCardDraft[];
|
||||
onCardsChange: (cards: TsCardDraft[]) => void;
|
||||
items: ItemDraft[];
|
||||
coverage: Record<string, string[]>; // cardKey → tempIdهای تیکخورده
|
||||
onCoverageChange: (cardKey: string, ids: string[]) => void;
|
||||
}
|
||||
|
||||
// پوشش مؤثر هر کارت: تیکهای صریح، یا (کارت اول بدون تیک) همهی اقلام
|
||||
export function effectiveCovered(cards: TsCardDraft[], coverage: Record<string, string[]>, items: ItemDraft[], cardKey: string): string[] {
|
||||
const explicit = coverage[cardKey];
|
||||
if (explicit && explicit.length) return explicit;
|
||||
if (cards[0]?.key === cardKey) return items.map((i) => i.tempId);
|
||||
return [];
|
||||
}
|
||||
|
||||
// مرحله ۳ — شرایط پرداخت (termsheetها) با چکلیست اقلام
|
||||
export function Step3Termsheets({ cards, onCardsChange, items, coverage, onCoverageChange }: Step3Props) {
|
||||
const itemOptions = items.map((it) => ({ tempId: it.tempId, label: itemRowLabel(it) }));
|
||||
|
||||
const addCard = () => {
|
||||
onCardsChange([
|
||||
...cards,
|
||||
{ key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6), milestones: [{ when_iso: null, amount: null }] },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeCard = (key: string) => {
|
||||
onCardsChange(cards.filter((c) => c.key !== key));
|
||||
onCoverageChange(key, []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
شرایط پرداخت — termsheet ها
|
||||
</h3>
|
||||
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر <b>termsheet</b> یک روش پرداخت است (نقد، چک، اقساط...). داخل هر termsheet مشخص کنید کدام قلمها را پوشش میدهد — یک termsheet میتواند چند قلم را بگیرد، و یک قلم هم میتواند زیر چند termsheet مختلف (مثلاً هم نقد هم چک) پیشنهاد شود.' }} />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{cards.map((card, idx) => (
|
||||
<TermsheetCard
|
||||
key={card.key}
|
||||
index={idx + 1}
|
||||
milestones={card.milestones}
|
||||
onMilestonesChange={(ms) => onCardsChange(cards.map((c) => (c.key === card.key ? { ...c, milestones: ms } : c)))}
|
||||
items={itemOptions}
|
||||
coveredIds={effectiveCovered(cards, coverage, items, card.key)}
|
||||
onCoverageChange={(ids) => onCoverageChange(card.key, ids)}
|
||||
onRemove={() => removeCard(card.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className="btn-line-file mt-3" onClick={addCard}>
|
||||
+ افزودن termsheet
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useState } from "react";
|
||||
import { DndContext, PointerSensor, 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 را میبلعد و کلیک-انتخاب کار نمیکند)
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { FileUpload } from "../../components/FileUpload";
|
||||
|
||||
export interface Step5Props {
|
||||
quoteDate: string | null;
|
||||
onQuoteDate: (iso: string | null) => void;
|
||||
expiryDate: string | null;
|
||||
onExpiryDate: (iso: string | null) => void;
|
||||
notes: string;
|
||||
onNotes: (v: string) => void;
|
||||
proforma: File | null;
|
||||
onProforma: (f: File | null) => void;
|
||||
// خلاصهی بازبینی
|
||||
summary: {
|
||||
supplierName: string;
|
||||
topicName: string;
|
||||
projects: string[];
|
||||
itemCount: number;
|
||||
tsCount: number;
|
||||
grandTotal: number;
|
||||
isOfficial: boolean;
|
||||
orphanCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
// مرحله ۵ — تاریخ، یادداشت، پیشفاکتور و بازبینی نهایی
|
||||
export function Step5Review({
|
||||
quoteDate,
|
||||
onQuoteDate,
|
||||
expiryDate,
|
||||
onExpiryDate,
|
||||
notes,
|
||||
onNotes,
|
||||
proforma,
|
||||
onProforma,
|
||||
summary,
|
||||
}: Step5Props) {
|
||||
const rows: [string, string][] = [
|
||||
["تأمینکننده", summary.supplierName],
|
||||
["موضوع", summary.topicName],
|
||||
["پروژهها", summary.projects.join("، ") || "—"],
|
||||
["تعداد اقلام", groupCommas(summary.itemCount, true)],
|
||||
["تعداد termsheet", groupCommas(summary.tsCount, true)],
|
||||
["جمع کل تقریبی", groupCommas(summary.grandTotal, true) + " تومان"],
|
||||
["فاکتور رسمی", summary.isOfficial ? "بله" : "خیر"],
|
||||
["پیشفاکتور", proforma ? proforma.name : "—"],
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-3 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="grid max-w-2xl grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label>تاریخ پیشنهاد</label>
|
||||
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
|
||||
<div className="hint">پیشفرض امروز</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ انقضای پیشنهاد</label>
|
||||
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
|
||||
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
|
||||
</div>
|
||||
<div className="field col-span-2">
|
||||
<label>یادداشت (اختیاری)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="توضیحات اضافی درباره تماس یا توافق..."
|
||||
value={notes}
|
||||
onChange={(e) => onNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field col-span-2">
|
||||
<label>پیشفاکتور (اختیاری)</label>
|
||||
{proforma ? (
|
||||
<div className="inv-row">
|
||||
<span className="inv-name">
|
||||
<Paperclip className="me-1 inline h-3.5 w-3.5" />
|
||||
{proforma.name}
|
||||
</span>
|
||||
<button type="button" className="inv-x" title="حذف" onClick={() => onProforma(null)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<FileUpload onUpload={onProforma} label="انتخاب فایل پیشفاکتور" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-xl border border-line bg-panel p-4 shadow-sm">
|
||||
<h4 className="mb-2 text-[13.5px] font-bold">خلاصهی پیشنهاد پیش از ذخیره</h4>
|
||||
{rows.map(([label, val]) => (
|
||||
<div key={label} className="review-row">
|
||||
<span className="r-label">{label}</span>
|
||||
<span className="r-val">{val}</span>
|
||||
</div>
|
||||
))}
|
||||
{summary.orphanCount > 0 && (
|
||||
<div className="review-row">
|
||||
<span className="r-label">هشدار</span>
|
||||
<span className="r-val review-warn">
|
||||
{faNum(summary.orphanCount)} قلم به هیچ termsheet وصل نیست -- در ذخیرهسازی بهطور خودکار به اولین termsheet وصل میشود
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface StepperProps {
|
||||
current: number;
|
||||
highestVisited: number;
|
||||
completedSteps: Set<number>;
|
||||
onGoto: (step: number) => void;
|
||||
}
|
||||
|
||||
const STEPS = ["تأمینکننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"];
|
||||
|
||||
// نوار ۵ مرحلهای ویزارد — کلیک روی مراحل بازدیدشده مجاز است
|
||||
// مراحل تکمیلشده سبز، مرحلهی فعلی آبی
|
||||
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm">
|
||||
{STEPS.map((label, i) => {
|
||||
const step = i + 1;
|
||||
const isCurrent = step === current;
|
||||
const isComplete = completedSteps.has(step);
|
||||
return (
|
||||
<button
|
||||
key={step}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors",
|
||||
isCurrent
|
||||
? "bg-[#2563eb] font-bold text-white"
|
||||
: isComplete
|
||||
? "bg-green-bg font-medium text-green"
|
||||
: "hover:bg-slate-bg",
|
||||
)}
|
||||
onClick={() => onGoto(step)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold",
|
||||
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted",
|
||||
)}
|
||||
>
|
||||
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import type { LineItem, Quote } from "../../lib/types";
|
||||
import { api, nextId } from "../../lib/api";
|
||||
import {
|
||||
useAddMaterial,
|
||||
useAddProject,
|
||||
useAddSupplier,
|
||||
useCreateQuote,
|
||||
useMaterials,
|
||||
useProjects,
|
||||
useQuotes,
|
||||
useStoreData,
|
||||
useSuppliers,
|
||||
} from "../../hooks";
|
||||
import { TOPICS } from "../../lib/constants";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { specsKey } from "../../lib/quoteUtils";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { Stepper } from "./Stepper";
|
||||
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
||||
import { Step2LineItems, type ItemDraft, type MaterialRec } from "./Step2LineItems";
|
||||
import { Step3Termsheets, effectiveCovered, type TsCardDraft } from "./Step3Termsheets";
|
||||
import { Step4Projects } from "./Step4Projects";
|
||||
import { Step5Review } from "./Step5Review";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
|
||||
// ویزارد ۵ مرحلهای افزودن پیشنهاد جدید
|
||||
export function WizardPage() {
|
||||
useStoreData();
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const projects = useProjects();
|
||||
const createQuote = useCreateQuote();
|
||||
const addSupplier = useAddSupplier();
|
||||
const addMaterial = useAddMaterial();
|
||||
const addProject = useAddProject();
|
||||
|
||||
// ---------- state ----------
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [highestVisited, setHighestVisited] = useState(1);
|
||||
|
||||
const [selectedTopic, setSelectedTopic] = useState("");
|
||||
const [supplierName, setSupplierName] = useState("");
|
||||
const [newSup, setNewSup] = useState({ location: "", phone: "", contact: "" });
|
||||
const [isOfficial, setIsOfficial] = useState(false);
|
||||
const [supplierInvalid, setSupplierInvalid] = useState(false);
|
||||
|
||||
const [items, setItems] = useState<ItemDraft[]>(() => [
|
||||
{ tempId: "it0", cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null },
|
||||
]);
|
||||
const [cards, setCards] = useState<TsCardDraft[]>(() => [
|
||||
{ key: "ts0", milestones: [{ when_iso: null, amount: null }] },
|
||||
]);
|
||||
const [coverage, setCoverage] = useState<Record<string, string[]>>({});
|
||||
const [assignment, setAssignment] = useState<Record<string, string[]>>({});
|
||||
|
||||
const [quoteDate, setQuoteDate] = useState<string | null>(() => {
|
||||
const d = new Date();
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
|
||||
});
|
||||
const [expiryDate, setExpiryDate] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [proforma, setProforma] = useState<File | null>(null);
|
||||
// دیالوگ ذخیره با فیلدهای خالی (نام فیلد + مرحلهی مربوطه)
|
||||
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
// ---------- prefill از لینکها (?topic=&supplier=) ----------
|
||||
// وقتی suppliers هنوز لود نشده، دوباره اجرا میشود
|
||||
useEffect(() => {
|
||||
const t = params.get("topic");
|
||||
if (!t) return;
|
||||
const topic = TOPICS.find((x) => normalize(x.name) === normalize(t));
|
||||
if (!topic) return;
|
||||
setSelectedTopic(topic.name);
|
||||
const sid = params.get("supplier");
|
||||
if (sid) {
|
||||
const sup = suppliers.find((s) => s.id === sid);
|
||||
if (sup && normalize(sup.topic) === normalize(topic.name)) {
|
||||
setSupplierName(sup.name);
|
||||
setSupplierInvalid(false);
|
||||
}
|
||||
}
|
||||
}, [params, suppliers]);
|
||||
|
||||
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
||||
// مراحل تکمیلشده را بر اساس دادههای فعلی مشخص میکند
|
||||
const completedSteps = useMemo(() => {
|
||||
const s = new Set<number>();
|
||||
// مرحله ۱: تاپیک انتخاب شده + نام تأمینکننده پر شده
|
||||
if (selectedTopic && supplierName.trim()) s.add(1);
|
||||
// مرحله ۲: حداقل یک قلم با تعداد و قیمت
|
||||
if (items.length > 0 && items.some((it) => it.qty != null && it.qty !== 0 && it.price != null && it.price !== 0)) s.add(2);
|
||||
// مرحله ۳: حداقل یک termsheet با مایلستون واقعی (تاریخ یا مبلغ) — نه فقط کارت پیشفرض
|
||||
if (
|
||||
cards.length > 0 &&
|
||||
cards.some((card) => (card.milestones || []).some((m) => m.when_iso || (m.amount != null && m.amount !== 0)))
|
||||
) {
|
||||
s.add(3);
|
||||
}
|
||||
// مرحله ۴: همه اقلام حداقل به یک پروژه اختصاص داده شده
|
||||
if (items.length > 0 && items.every((it) => (assignment[it.tempId] || []).length > 0)) s.add(4);
|
||||
// مرحله ۵: همیشه بازدیدشده محسوب میشود
|
||||
if (highestVisited >= 5) s.add(5);
|
||||
return s;
|
||||
}, [selectedTopic, supplierName, items, cards, coverage, assignment, highestVisited]);
|
||||
|
||||
const goToStep = (n: number) => {
|
||||
setCurrentStep(n);
|
||||
setHighestVisited((h) => Math.max(h, n));
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
goToStep(currentStep + 1);
|
||||
};
|
||||
|
||||
const rememberMaterial = (rec: MaterialRec) => {
|
||||
void addMaterial.mutateAsync({
|
||||
id: nextId("m", materials),
|
||||
name: [rec.brand, rec.category, ...rec.specs].filter(Boolean).join(" ") || "کالای جدید",
|
||||
topic: rec.topic,
|
||||
category: rec.category,
|
||||
brand: rec.brand,
|
||||
specs: rec.specs,
|
||||
base_unit: "",
|
||||
});
|
||||
};
|
||||
|
||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
||||
const projectNames = useMemo(() => {
|
||||
const names = new Set<string>(projects.map((p) => p.name));
|
||||
quotes.forEach((q) => {
|
||||
if (q.project) names.add(q.project);
|
||||
(q.projects || []).forEach((n) => names.add(n));
|
||||
});
|
||||
return [...names];
|
||||
}, [projects, quotes]);
|
||||
|
||||
const assign = (tempId: string, proj: string) => {
|
||||
setAssignment((a) => {
|
||||
const cur = a[tempId] || [];
|
||||
if (cur.includes(proj)) return a;
|
||||
return { ...a, [tempId]: [...cur, proj] };
|
||||
});
|
||||
};
|
||||
|
||||
const unassign = (tempId: string, proj: string) => {
|
||||
setAssignment((a) => {
|
||||
const cur = (a[tempId] || []).filter((p) => p !== proj);
|
||||
const next = { ...a };
|
||||
if (cur.length) next[tempId] = cur;
|
||||
else delete next[tempId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ---------- خلاصهی مرحله ۵ ----------
|
||||
const review = useMemo(() => {
|
||||
let grandTotal = 0;
|
||||
items.forEach((it) => {
|
||||
const t = it.total;
|
||||
if (t) grandTotal += t;
|
||||
});
|
||||
const projSet = new Set<string>();
|
||||
items.forEach((it) => (assignment[it.tempId] || []).forEach((p) => projSet.add(p)));
|
||||
const coveredTempIds = new Set<string>();
|
||||
cards.forEach((card) =>
|
||||
effectiveCovered(cards, coverage, items, card.key).forEach((tid) => coveredTempIds.add(tid)),
|
||||
);
|
||||
const orphanCount = items.filter((it) => !coveredTempIds.has(it.tempId)).length;
|
||||
return {
|
||||
supplierName: supplierName.trim() || "—",
|
||||
topicName: selectedTopic || "—",
|
||||
projects: [...projSet],
|
||||
itemCount: items.length,
|
||||
tsCount: cards.length,
|
||||
grandTotal,
|
||||
isOfficial,
|
||||
orphanCount,
|
||||
};
|
||||
}, [items, assignment, cards, coverage, supplierName, selectedTopic, isOfficial]);
|
||||
|
||||
// ---------- ذخیره ----------
|
||||
// تابع ذخیرهی اصلی — با یا بدون فیلدهای خالی (انتخاب کاربر در دیالوگ)
|
||||
const doSave = async () => {
|
||||
// خطای بخش — با دکمهی «رفع خطا» که به همان بخش میبرد
|
||||
const sectionError = (step: number, title: string, err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
||||
toast.error(`${title}: ${msg}`, {
|
||||
action: { label: "رفع خطا", onClick: () => goToStep(step) },
|
||||
});
|
||||
};
|
||||
// مشخص کردن بخش خطا از روی متن پیام
|
||||
const guessStep = (msg: string): number => {
|
||||
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
|
||||
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
|
||||
if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
|
||||
if (/پروژه|project/i.test(msg)) return 4;
|
||||
return 5;
|
||||
};
|
||||
|
||||
// تأمینکننده
|
||||
const name = supplierName.trim();
|
||||
let supplierId = "";
|
||||
if (name) {
|
||||
const exist = suppliers.find((s) => normalize(s.name) === normalize(name));
|
||||
if (exist) {
|
||||
supplierId = exist.id;
|
||||
} else {
|
||||
try {
|
||||
const created = await addSupplier.mutateAsync({
|
||||
id: nextId("s", suppliers),
|
||||
topic: selectedTopic,
|
||||
categories: [],
|
||||
name,
|
||||
location: newSup.location.trim(),
|
||||
phone: newSup.phone.trim(),
|
||||
contact: newSup.contact.trim(),
|
||||
});
|
||||
supplierId = created.id;
|
||||
} catch (err) {
|
||||
sectionError(1, "خطا در بخش تأمینکننده", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// اقلام
|
||||
const collected: { li: LineItem; tempId: string }[] = [];
|
||||
for (const it of items) {
|
||||
const cat = it.cat.trim();
|
||||
const brand = it.brand.trim();
|
||||
const specs = it.specs;
|
||||
const unit = it.unit.trim();
|
||||
const qty = it.qty;
|
||||
const price = it.price;
|
||||
const total = it.total;
|
||||
if (!cat && !brand && qty == null && price == null) continue;
|
||||
|
||||
// جستجوی متریال: اول با نام (مثل بکاند ساخته شده) — بعد از رفرش،
|
||||
// برند/مشخصه در حافظه نیستند ولی نام کامل در دیتابیس ذخیره شده
|
||||
const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
|
||||
let material = materials.find(
|
||||
(m) => normalize(m.category) === normalize(cat) && normalize(m.name) === normalize(wantName),
|
||||
);
|
||||
if (!material) {
|
||||
// جستجوی قبلی (برند و مشخصهی ساختارمند در همین نشست)
|
||||
material = materials.find(
|
||||
(m) =>
|
||||
normalize(m.category) === normalize(cat) &&
|
||||
normalize(m.brand) === normalize(brand) &&
|
||||
specsKey(m.specs) === specsKey(specs),
|
||||
);
|
||||
}
|
||||
let materialId: string;
|
||||
if (material) {
|
||||
materialId = material.id;
|
||||
} else {
|
||||
try {
|
||||
const created = await addMaterial.mutateAsync({
|
||||
id: nextId("m", materials),
|
||||
name: wantName,
|
||||
topic: selectedTopic,
|
||||
category: cat,
|
||||
brand,
|
||||
specs,
|
||||
base_unit: unit,
|
||||
});
|
||||
materialId = created.id; // UUID واقعی از بکاند
|
||||
} catch (err) {
|
||||
sectionError(2, "خطا در بخش اقلام (متریال)", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
collected.push({
|
||||
li: {
|
||||
id: "li-" + (collected.length + 1),
|
||||
material_id: materialId,
|
||||
quantity: qty,
|
||||
unit,
|
||||
unit_price: price,
|
||||
total_price: total,
|
||||
termsheet_ids: [],
|
||||
accepted_ts_id: null,
|
||||
rejected: false,
|
||||
arrived_date: null,
|
||||
},
|
||||
tempId: it.tempId,
|
||||
});
|
||||
}
|
||||
|
||||
const lineItems = collected.map((c) => c.li);
|
||||
|
||||
// termsheetها
|
||||
const termsheets = cards
|
||||
.map((card, i) => ({
|
||||
id: "new-ts-" + (i + 1),
|
||||
label: "گزینه " + faNum(i + 1),
|
||||
milestones: card.milestones.filter((m) => m.when_iso || m.amount != null),
|
||||
status: "offered" as const,
|
||||
}))
|
||||
.filter((t) => t.milestones.length);
|
||||
|
||||
// اتصال اقلام به termsheetها
|
||||
collected.forEach(({ li, tempId }) => {
|
||||
const ids = cards
|
||||
.map((card, i) =>
|
||||
effectiveCovered(cards, coverage, items, card.key).includes(tempId) ? "new-ts-" + (i + 1) : "",
|
||||
)
|
||||
.filter(Boolean);
|
||||
const valid = ids.filter((tid) => termsheets.some((t) => t.id === tid));
|
||||
li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
|
||||
});
|
||||
|
||||
// پروژهها
|
||||
const projSet = new Set<string>();
|
||||
collected.forEach(({ li, tempId }) => {
|
||||
const list = assignment[tempId] || [];
|
||||
li.projects = list.map((p) => ({ id: "", name: p }));
|
||||
list.forEach((p) => projSet.add(p));
|
||||
});
|
||||
const projectsList = [...projSet];
|
||||
|
||||
const quoteIso = quoteDate || new Date().toISOString();
|
||||
const quote: Quote = {
|
||||
id: nextId("q", quotes),
|
||||
supplier_id: supplierId,
|
||||
project: projectsList[0] || "",
|
||||
projects: projectsList,
|
||||
quote_date: quoteIso,
|
||||
expiry_date: expiryDate,
|
||||
accepted_date: null,
|
||||
arrived_date: null,
|
||||
status: "offered",
|
||||
is_official: isOfficial,
|
||||
proformas: [],
|
||||
invoices: [],
|
||||
events: [{ type: "offered", occurred_at: quoteIso }],
|
||||
notes: notes.trim(),
|
||||
line_items: lineItems,
|
||||
termsheets,
|
||||
};
|
||||
|
||||
try {
|
||||
const created = await createQuote.mutateAsync(quote);
|
||||
// آپلود پیشفاکتور به MinIO بعد از ساخت پیشنهاد
|
||||
if (proforma) {
|
||||
try {
|
||||
await api.files.upload(created.id, "proforma", proforma);
|
||||
} catch (err) {
|
||||
toast.error(`خطا در آپلود پیشفاکتور: ${err instanceof Error ? err.message : "خطای ناشناخته"}`, {
|
||||
action: { label: "رفع خطا", onClick: () => goToStep(5) },
|
||||
});
|
||||
}
|
||||
}
|
||||
toast.success("پیشنهاد ذخیره شد.");
|
||||
navigate("/");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "خطا در ذخیره پیشنهاد";
|
||||
sectionError(guessStep(msg), "خطا در ثبت پیشنهاد", e);
|
||||
}
|
||||
};
|
||||
|
||||
// ذخیره — اگر بخشی ناقص است، دیالوگ تأیید با لینک فیلدهای خالی باز میشود
|
||||
const save = () => {
|
||||
const incomplete: { label: string; step: number }[] = [];
|
||||
if (!completedSteps.has(1)) incomplete.push({ label: "تأمینکننده", step: 1 });
|
||||
if (!completedSteps.has(2)) incomplete.push({ label: "اقلام", step: 2 });
|
||||
if (!completedSteps.has(3)) incomplete.push({ label: "شرایط پرداخت", step: 3 });
|
||||
if (!completedSteps.has(4)) incomplete.push({ label: "پروژه", step: 4 });
|
||||
if (incomplete.length > 0) {
|
||||
setEmptyFields(incomplete);
|
||||
setConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
void doSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="افزودن پیشنهاد جدید"
|
||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||
backTo="/"
|
||||
backLabel="بازگشت به دفتر"
|
||||
/>
|
||||
<PageLayout>
|
||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||
|
||||
{currentStep === 1 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step1TopicSupplier
|
||||
selectedTopic={selectedTopic}
|
||||
onTopicSelect={(t) => {
|
||||
setSelectedTopic(t);
|
||||
setSupplierName("");
|
||||
setNewSup({ location: "", phone: "", contact: "" });
|
||||
setIsOfficial(false);
|
||||
setSupplierInvalid(false);
|
||||
}}
|
||||
supplierName={supplierName}
|
||||
onSupplierName={(v) => {
|
||||
setSupplierName(v);
|
||||
setSupplierInvalid(false);
|
||||
}}
|
||||
newSup={newSup}
|
||||
onNewSup={(p) => setNewSup((s) => ({ ...s, ...p }))}
|
||||
isOfficial={isOfficial}
|
||||
onOfficial={setIsOfficial}
|
||||
invalid={supplierInvalid}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step3Termsheets
|
||||
cards={cards}
|
||||
onCardsChange={setCards}
|
||||
items={items}
|
||||
coverage={coverage}
|
||||
onCoverageChange={(key, ids) => setCoverage((c) => ({ ...c, [key]: ids }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step4Projects
|
||||
items={items}
|
||||
projects={projectNames}
|
||||
assignment={assignment}
|
||||
onAssign={assign}
|
||||
onUnassign={unassign}
|
||||
onAddProject={(name) => {
|
||||
void addProject.mutateAsync({ id: nextId("p", projects), name });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 5 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step5Review
|
||||
quoteDate={quoteDate}
|
||||
onQuoteDate={setQuoteDate}
|
||||
expiryDate={expiryDate}
|
||||
onExpiryDate={setExpiryDate}
|
||||
notes={notes}
|
||||
onNotes={setNotes}
|
||||
proforma={proforma}
|
||||
onProforma={setProforma}
|
||||
summary={review}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* پاورقی ویزارد */}
|
||||
<div className="mt-4 flex items-center gap-2 rounded-xl border border-line bg-panel p-3 shadow-sm">
|
||||
{currentStep > 1 && (
|
||||
<Button variant="slate" onClick={() => goToStep(currentStep - 1)}>
|
||||
‹ قبلی
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Link to="/" className="btn-line-file no-underline">
|
||||
انصراف
|
||||
</Link>
|
||||
{currentStep < TOTAL_STEPS && <Button onClick={next}>بعدی ›</Button>}
|
||||
{currentStep === TOTAL_STEPS && <Button onClick={() => void save()}>ذخیره پیشنهاد</Button>}
|
||||
</div>
|
||||
|
||||
{/* دیالوگ ذخیره با فیلدهای خالی */}
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>فیلدهای خالی</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-[13.5px] leading-7">
|
||||
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
||||
{emptyFields.map((f) => (
|
||||
<button
|
||||
key={f.step}
|
||||
type="button"
|
||||
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false);
|
||||
goToStep(f.step);
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button
|
||||
variant="green"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false);
|
||||
void doSave();
|
||||
}}
|
||||
>
|
||||
ذخیره کردن
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-red text-white hover:brightness-95"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false);
|
||||
goToStep(emptyFields[0]?.step ?? 1);
|
||||
}}
|
||||
>
|
||||
تکمیل فیلد ها
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user