feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login

This commit is contained in:
ali_mohaghegh
2026-08-25 19:08:15 +03:30
parent 170aed66ea
commit 4e105c5cd5
103 changed files with 16352 additions and 0 deletions
+120
View File
@@ -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>
);
}
+655
View File
@@ -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>
);
}
+226
View File
@@ -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>
);
}
+185
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+160
View File
@@ -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>
);
}
+657
View File
@@ -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>
);
}
+239
View File
@@ -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>
);
}
+117
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+542
View File
@@ -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>
);
}