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.
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
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";
|
||||
@@ -16,9 +27,8 @@ function todayKey(): string {
|
||||
return gregKey(new Date());
|
||||
}
|
||||
|
||||
// کنترل روزانه — پورت کامل تقویم شمسی نسخهی ارسالی
|
||||
export function DailyControlPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const data = useInventoryData();
|
||||
const navigate = useNavigate();
|
||||
const isPM = user?.role === "pm";
|
||||
@@ -26,7 +36,6 @@ export function DailyControlPage() {
|
||||
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("");
|
||||
|
||||
@@ -46,7 +55,6 @@ export function DailyControlPage() {
|
||||
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;
|
||||
@@ -59,7 +67,7 @@ export function DailyControlPage() {
|
||||
}
|
||||
}
|
||||
if (!first) first = new Date(anchor.getTime() - 33 * 86400000);
|
||||
const offset = (first.getDay() + 1) % 7; // شنبه = 0
|
||||
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++) {
|
||||
@@ -79,7 +87,6 @@ export function DailyControlPage() {
|
||||
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;
|
||||
@@ -90,18 +97,12 @@ export function DailyControlPage() {
|
||||
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();
|
||||
@@ -144,114 +145,219 @@ export function DailyControlPage() {
|
||||
const [sy, sm, sd] = gregorianToJalali(selDate.getFullYear(), selDate.getMonth() + 1, selDate.getDate());
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="min-h-screen bg-slate-50/60">
|
||||
<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
|
||||
? "نمایش کنترل روزانه فقط برای پروژه انتخابی"
|
||||
: "نمایش کنترل روزانه همه پروژهها"}
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<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">
|
||||
|
||||
{/* روزهای هفته */}
|
||||
<div className="grid grid-cols-7 gap-1 text-center">
|
||||
{["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => (
|
||||
<div key={d} className="py-1 text-center text-[11px] font-bold text-muted">{d}</div>
|
||||
<div key={d} className="py-1 text-xs font-semibold text-slate-400">{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);
|
||||
const isSelected = sel === k;
|
||||
|
||||
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"}`}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{toFa(jd)}
|
||||
<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-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 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-muted">
|
||||
مواردی که این روز از انبار برداشت و به ساختمان برده شدهاند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید.
|
||||
|
||||
<p className="mb-3 text-[11.5px] leading-5 text-slate-500">
|
||||
اقلام برداشتشده از انبار در این تاریخ:
|
||||
</p>
|
||||
|
||||
{!wGroups.length ? (
|
||||
<div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">
|
||||
{isToday ? "امروز هنوز برداشتی ثبت نشده است." : "در این روز برداشتی ثبت نشده است."} هر برداشتی که در «تغییر موجودی» ثبت کنید، همینجا نمایش داده میشود.
|
||||
<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-1.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
{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>
|
||||
<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-[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 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-[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>
|
||||
<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 flex gap-1.5 border-t border-line pt-2">
|
||||
<div className="mt-2.5 flex flex-wrap gap-2 border-t border-slate-100 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"
|
||||
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
|
||||
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
|
||||
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
|
||||
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
|
||||
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>
|
||||
)}
|
||||
@@ -264,4 +370,4 @@ export function DailyControlPage() {
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user