feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user