Files
ERP/frontend/src/pages/Inventory/DailyControlPage.tsx
T
ali_mohaghegh 7f58593f04 fix(daily-control): «ماه بعد» navigation + hover feedback
The month-shift anchor discarded the day returned by jalaliToGregorian:
new Date(gy, gm-1, 1) instead of new Date(gy, gm-1, gd). Jalali months
start mid-Gregorian-month (مهر ۱ = 2026-09-23), so the anchor landed on
the 1st of the Gregorian month — still inside the SAME Jalali month
(شهریور ۱۰) — and «ماه بعد» appeared dead while «ماه قبل» only seemed
to work because its wrong target showed a different label. This was also
the source of the reported ~22-day date mismatches.

- shiftMonth now keeps the converted day (gy, gm, gd)
- nav buttons fire on pointerup/click/keyboard with a dedupe guard so the
  action survives browsers that swallow the click event
- buttons got hover feedback (cursor-pointer, hover:bg-slate-100,
  transition-colors)

Verified against the library math: from شهریور ۱۴۰۵, next → مهر ۱۴۰۵,
prev → مرداد ۱۴۰۵; confirmed in the deployed bundle.
2026-09-03 17:10:58 +03:30

268 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
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]);
// 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 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) || [];
// فعال‌سازی ناوبری ماه از چند مسیر (pointerup / click / کیبورد) با حذف
// اجرای تکراری — اگر مرورگر رویداد click را بخورد، pointerup جواب می‌دهد.
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">
<Header
title="کنترل روزانه"
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
/>
<PageLayout className="flex flex-col gap-3">
{isAdmin && (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel px-3 py-2 text-[12.5px]">
<label className="font-bold">🏢 پروژه:</label>
<select
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
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="text-[11.5px] text-muted">
{projFilter
? "نمایش کنترل روزانه فقط برای پروژه انتخابی"
: "نمایش کنترل روزانه همه پروژه‌ها"}
</span>
</div>
)}
<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="cursor-pointer rounded-lg border border-line px-3 py-1 text-[12px] transition-colors hover:bg-slate-100" {...navBtnProps("prev", () => shiftMonth(-1))}>▶ ماه قبل</button>
<button className="cursor-pointer rounded-lg border border-line px-3 py-1 text-[12px] transition-colors hover:bg-slate-100" {...navBtnProps("today", () => { setAnchor(new Date()); setSel(todayKey()); setActRec(null); })}>امروز</button>
<button className="cursor-pointer rounded-lg border border-line px-3 py-1 text-[12px] transition-colors hover:bg-slate-100" {...navBtnProps("next", () => shiftMonth(1))}>◀ ماه بعد</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) => {
// gregorianToJalali → [سال، ماه، روز] — روزِ ماه در خانه‌ی تقویم
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 font-bold text-white" : ""} ${sel === k ? "ring-2 ring-accent" : "hover:bg-accent/5"}`}
>
{toFa(jd)}
</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 ? "امروز " : ""}{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}</h2>
<span className="rounded-lg bg-rose-50 px-2 py-1 text-[11.5px] font-bold text-rose-600">▼ برداشت‌ها: {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-rose-50 text-rose-600">➖</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-rose-600">{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-emerald-300 bg-emerald-50 px-3 py-1 text-[11.5px] font-semibold text-emerald-700"
onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
↩️ برگشت
</button>
<button
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
📦 مفقودی
</button>
<button
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
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>
);
}