Files
ERP/frontend/src/pages/Inventory/DailyControlPage.tsx
T
MMD 2573f1e583 refactor(ui): modernize proposal wizard, dashboard cards and daily control calendar
Frontend-only application of prod_v2 (7bc6845, a29eafc). Dev
docker-compose.yml change (b11cb36) and backups churn excluded.
2026-09-13 18:07:27 +03:30

373 lines
17 KiB
TypeScript

import { useMemo, useRef, useState } from "react";
import { useNavigate, Link } from "react-router-dom";
import {
ChevronRight,
ChevronLeft,
Calendar as CalendarIcon,
Building2,
Plus,
RotateCcw,
PackageX,
Trash2,
Clock
} from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu";
import { useInventoryData, unitName, whName } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
import { gregorianToJalali, jalaliToGregorian, 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 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 isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const [projFilter, setProjFilter] = useState("");
const txByDay = useMemo(() => {
const map = new Map<string, InventoryTransaction[]>();
const projWhIds = projFilter
? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id))
: null;
data.transactions.forEach((t) => {
if (projWhIds && !projWhIds.has(t.warehouse_id)) return;
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, data.warehouses, projFilter]);
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;
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 shiftMonth = (delta: number) => {
let nm = tm + delta;
let ny = ty;
if (nm < 1) {
nm = 12;
ny -= 1;
} else if (nm > 12) {
nm = 1;
ny += 1;
}
const [gy, gm, gd] = jalaliToGregorian(ny, nm, 1);
setAnchor(new Date(gy, gm - 1, gd));
};
const dayTx = txByDay.get(sel) || [];
const navLast = useRef<{ key: string; at: number }>({ key: "", at: 0 });
const navFire = (key: string, action: () => void) => {
const now = Date.now();
if (navLast.current.key === key && now - navLast.current.at < 350) return;
navLast.current = { key, at: now };
action();
};
const navBtnProps = (key: string, action: () => void) => ({
type: "button" as const,
onPointerUp: (e: React.PointerEvent) => {
e.preventDefault();
navFire(key, action);
},
onClick: () => navFire(key, action),
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navFire(key, action);
}
},
});
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]));
const [sy, sm, sd] = gregorianToJalali(selDate.getFullYear(), selDate.getMonth() + 1, selDate.getDate());
return (
<div className="min-h-screen bg-slate-50/60">
<Header
title="کنترل روزانه"
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
/>
<PageLayout className="flex flex-col gap-4">
{/* نوار پروژه سفید و شفاف */}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white p-3 shadow-xs">
<div className="flex flex-wrap items-center gap-2.5 text-xs">
<span className="flex items-center gap-1.5 font-bold text-slate-800">
<Building2 className="h-4 w-4 text-blue-600" />
<span>پروژه:</span>
</span>
{isAdmin ? (
<select
className="h-8 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 focus:outline-none focus:border-blue-500"
value={projFilter}
onChange={(e) => setProjFilter(e.target.value)}
>
<option value="">همه پروژه‌ها</option>
{data.lookup.projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
) : (
<span className="font-semibold text-slate-800">پروژه فعال</span>
)}
<span className="text-[11.5px] text-slate-400">
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژه‌ها"}
</span>
</div>
<Link
to="/inventory/change"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold shadow-xs transition-colors no-underline"
>
<Plus className="h-3.5 w-3.5" />
<span>ثبت خروج / تغییر موجودی</span>
</Link>
</div>
<div className="grid gap-4 lg:grid-cols-2 items-start">
{/* ستون تقویم — سفید، خوانا و بدون هدر تیره */}
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-xs">
<div className="mb-4 flex items-center justify-between pb-3 border-b border-slate-100">
<div className="flex items-center gap-2 text-[14px] font-bold text-slate-800">
<CalendarIcon className="h-4 w-4 text-blue-600" />
<span>{monthTitle}</span>
</div>
<div className="flex items-center gap-1.5">
<button
className="inline-flex items-center gap-1 h-7.5 px-2.5 rounded-md border border-slate-200 bg-white text-xs font-medium text-slate-700 hover:border-slate-300 transition-colors cursor-pointer"
{...navBtnProps("prev", () => shiftMonth(-1))}
>
<ChevronRight className="h-3.5 w-3.5" />
<span>ماه قبل</span>
</button>
<button
className="h-7.5 px-3 rounded-md border border-blue-200 bg-blue-50 text-xs font-bold text-blue-700 hover:bg-blue-100 transition-colors cursor-pointer"
{...navBtnProps("today", () => { setAnchor(new Date()); setSel(todayKey()); setActRec(null); })}
>
امروز
</button>
<button
className="inline-flex items-center gap-1 h-7.5 px-2.5 rounded-md border border-slate-200 bg-white text-xs font-medium text-slate-700 hover:border-slate-300 transition-colors cursor-pointer"
{...navBtnProps("next", () => shiftMonth(1))}
>
<span>ماه بعد</span>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* روزهای هفته */}
<div className="grid grid-cols-7 gap-1 text-center">
{["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => (
<div key={d} className="py-1 text-xs font-semibold text-slate-400">{d}</div>
))}
{cells.map((c, i) => {
const [, , jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
const k = gregKey(c.d);
const isSelected = sel === k;
return (
<button
key={i}
type="button"
onClick={() => { setSel(k); setActRec(null); }}
className={`relative flex flex-col items-center justify-center h-9 rounded-lg text-xs transition-colors cursor-pointer ${
!c.inMonth ? "text-slate-300" : "text-slate-700 font-medium"
} ${
isSelected
? "bg-blue-600 text-white font-bold"
: c.isToday
? "border border-blue-500 font-bold text-blue-600 hover:bg-blue-50/50"
: "hover:bg-slate-100/70"
}`}
>
<span>{toFa(jd)}</span>
{c.n > 0 && (
<span
className={`absolute bottom-1 h-1 w-1 rounded-full ${
isSelected ? "bg-white" : "bg-blue-600"
}`}
/>
)}
</button>
);
})}
</div>
</div>
{/* ستون جزئیات روز — سفید یکدست */}
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-xs">
<div className="mb-2 flex items-center justify-between pb-3 border-b border-slate-100">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-blue-600" />
<h2 className="text-sm font-bold text-slate-800">
{isToday ? "امروز — " : "کنترل — "}
{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}
</h2>
</div>
<span
className={`rounded-md px-2 py-0.5 text-xs font-semibold ${
wGroups.length > 0
? "bg-amber-50 text-amber-700 border border-amber-200"
: "text-slate-400 border border-slate-100"
}`}
>
{faNum(wGroups.length)} برداشت
</span>
</div>
<p className="mb-3 text-[11.5px] leading-5 text-slate-500">
اقلام برداشت‌شده از انبار در این تاریخ:
</p>
{!wGroups.length ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-slate-200 py-10 px-4 text-center">
<span className="text-xs text-slate-400 mb-2">
{isToday ? "امروز هنوز هیچ برداشتی ثبت نشده است." : "در این تاریخ برداشتی ثبت نشده است."}
</span>
<Link
to="/inventory/change"
className="inline-flex items-center gap-1 px-3 py-1 rounded text-blue-600 hover:bg-blue-50 text-xs font-semibold transition-colors no-underline"
>
<Plus className="h-3 w-3" />
<span>ثبت برداشت جدید</span>
</Link>
</div>
) : (
<div className="flex flex-col gap-2">
{wGroups.map((g) => (
<div
key={g.recId}
className={`rounded-lg border p-3 transition-colors ${
actRec === g.recId
? "border-blue-500 bg-blue-50/20"
: "border-slate-100 bg-white hover:border-slate-200"
}`}
>
<button
type="button"
className="flex w-full items-center gap-3 text-start cursor-pointer"
onClick={() => setActRec(actRec === g.recId ? null : g.recId)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-bold text-slate-800">
{g.tx.material?.name || "—"}
</span>
<span className="block truncate text-[11px] text-slate-400 mt-0.5">
{[
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-xs font-bold text-rose-600 tabular-nums">
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
</span>
<span className="text-[11px] text-slate-400 tabular-nums">
{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}
</span>
</button>
{actRec === g.recId && isPM && (
<div className="mt-2.5 flex flex-wrap gap-2 border-t border-slate-100 pt-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-emerald-300 bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700 hover:bg-emerald-100 transition-colors cursor-pointer"
onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
<RotateCcw className="h-3 w-3" />
<span>برگشت</span>
</button>
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-rose-300 bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700 hover:bg-rose-100 transition-colors cursor-pointer"
onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
<PackageX className="h-3 w-3" />
<span>مفقودی</span>
</button>
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-700 hover:bg-slate-50 transition-colors cursor-pointer"
onClick={() => navigate(`/inventory/change?type=WASTE&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
<Trash2 className="h-3 w-3" />
<span>اتلاف</span>
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</PageLayout>
</div>
);
}