feat(ui): modernize button design system and update Dashboard and Inventory pages

Frontend-only cherry-pick of prod_v2 3bdb05f; the dev compose
api port change (8000 -> 8001) is intentionally excluded.
This commit is contained in:
MMD
2026-09-13 00:13:57 +03:30
parent 3b032d0460
commit 50f8d8e64e
4 changed files with 175 additions and 120 deletions
+5 -5
View File
@@ -4,17 +4,17 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98] [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-accent text-white shadow-sm hover:bg-accent-dark",
primary: "bg-accent text-white shadow-sm hover:bg-accent-dark",
slate: "bg-slate-bg text-ink hover:bg-line",
line: "border border-line bg-panel text-ink hover:bg-slate-bg",
slate: "bg-slate-bg text-ink border border-transparent hover:brightness-95",
line: "border border-line bg-panel text-ink shadow-sm hover:bg-slate-bg",
ghost: "hover:bg-slate-bg text-ink",
green: "bg-green text-white hover:brightness-95",
danger: "bg-red-bg text-red hover:bg-red hover:text-white",
green: "bg-green text-white shadow-sm hover:brightness-95",
danger: "bg-red-bg text-red shadow-sm hover:bg-red hover:text-white",
},
size: {
default: "h-9 px-4 py-2",
+28 -16
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
import { Button } from "../../components/ui/button";
import {
useMaterials,
useProjects,
@@ -80,22 +81,33 @@ export function DashboardPage() {
menu={<AppSwitcherMenu activeModule="procurement" />}
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>
<Button asChild variant="slate">
<Link to="/suppliers" className="no-underline">
<Users className="h-4 w-4" />
تأمین‌کننده‌ها
</Link>
</Button>
<Button asChild variant="slate">
<Link to="/projects" className="no-underline">
<FolderKanban className="h-4 w-4" />
پروژه‌ها
</Link>
</Button>
<Button asChild variant="line">
<Link to="/units" className="no-underline">
<Ruler className="h-4 w-4" />
ضرایب تبدیل
</Link>
</Button>
<Button asChild variant="primary">
<Link to="/wizard" className="no-underline">
<Plus className="h-4 w-4" />
افزودن پیشنهاد جدید
</Link>
</Button>
</>
}
/>
+128 -79
View File
@@ -1,9 +1,10 @@
import { useMemo, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { Plus, Warehouse as WarehouseIcon } from "lucide-react";
import { Plus, Warehouse as WarehouseIcon, ArrowUpDown, ArrowUp, ArrowDown, X } from "lucide-react";
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 { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { inventorySubheader } from "./panelMenu";
@@ -52,20 +53,21 @@ export function InventoryPage() {
const location = useLocation();
const user = useAuthStore((s) => s.user);
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; // مدیر تدارکات = ادمین دفتر
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm";
// پرش عمیق از «انبارها»: /inventory?warehouse=<id> → داشبورد همان انبار از پیش فیلتر شده
const [params] = useSearchParams();
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({
category: "", warehouse: params.get("warehouse") || "", material: "", project: "",
category: "",
warehouse: params.get("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;
@@ -86,7 +88,18 @@ export function InventoryPage() {
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: [] };
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);
}
@@ -145,24 +158,23 @@ export function InventoryPage() {
<>
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
{isPM && (
<Link
to="/inventory/change"
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
>
<Plus className="h-3.5 w-3.5" />
تغییر موجودی
</Link>
<Button asChild variant="primary">
<Link to="/inventory/change" className="no-underline">
<Plus className="h-4 w-4" />
تغییر موجودی
</Link>
</Button>
)}
</>
}
/>
<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>
<PageLayout className="flex flex-col gap-4">
{/* نوار فیلترها و ابزارها */}
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-line bg-panel p-3.5 shadow-sm">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted">دسته‌بندی</label>
<select
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
className="h-9 min-w-[150px] rounded-md border border-line bg-panel px-2.5 text-xs text-ink transition-all focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
value={filters.category}
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
>
@@ -172,11 +184,12 @@ export function InventoryPage() {
))}
</select>
</div>
{isAdmin && (
<div className="field">
<label>پروژه</label>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted">پروژه</label>
<select
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
className="h-9 min-w-[140px] rounded-md border border-line bg-panel px-2.5 text-xs text-ink transition-all focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
value={filters.project}
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
>
@@ -187,10 +200,11 @@ export function InventoryPage() {
</select>
</div>
)}
<div className="field">
<label>انبار</label>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted">انبار</label>
<select
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
className="h-9 min-w-[140px] rounded-md border border-line bg-panel px-2.5 text-xs text-ink transition-all focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
value={filters.warehouse}
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
>
@@ -202,52 +216,82 @@ export function InventoryPage() {
))}
</select>
</div>
<div className="field">
<label>متریال</label>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted">متریال</label>
<input
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
className="h-9 w-48 rounded-md border border-line bg-panel px-3 text-xs text-ink transition-all placeholder:text-muted/70 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
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]"
<Button
variant="line"
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
className="gap-1.5"
>
{sort === "asc" ? "تعداد: صعودی ▲" : sort === "desc" ? "تعداد: نزولی ▼" : "مرتب‌سازی تعداد"}
</button>
{sort === "asc" ? (
<>
<ArrowUp className="h-3.5 w-3.5 text-accent" />
تعداد: صعودی
</>
) : sort === "desc" ? (
<>
<ArrowDown className="h-3.5 w-3.5 text-accent" />
تعداد: نزولی
</>
) : (
<>
<ArrowUpDown className="h-3.5 w-3.5 text-muted" />
مرتب‌سازی تعداد
</>
)}
</Button>
{hasFilter && (
<button
className="h-8 rounded-lg border border-red/30 bg-red/5 px-3 text-[12px] text-red"
<Button
variant="danger"
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
className="gap-1"
>
✕ پاک کردن فیلترها
</button>
<X className="h-3.5 w-3.5" />
پاک کردن فیلترها
</Button>
)}
{filters.warehouse && (
<Badge variant="offered">🏬 {whName(warehouses, filters.warehouse)}</Badge>
)}
<Badge variant="outline">{faNum(groups.length)} قلم کالا</Badge>
<div className="ms-auto flex items-center gap-2 self-end">
{filters.warehouse && (
<Badge variant="offered" className="rounded-md px-2.5 py-1 text-xs">
🏬 {whName(warehouses, filters.warehouse)}
</Badge>
)}
<Badge variant="outline" className="rounded-md border-line bg-slate-bg px-3 py-1.5 text-xs font-normal text-muted">
{faNum(groups.length)} قلم کالا
</Badge>
</div>
</div>
{/* جدول گروه‌بندی‌شده */}
<div className="overflow-hidden rounded-xl border border-line bg-panel">
{!groups.length ? (
{/* بخش جدول یا وضعیت خالی */}
{!groups.length ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-line bg-panel/40 py-16 px-4 shadow-xs">
<EmptyState emoji="📋" text="هیچ رکورد موجودی‌ای مطابق فیلترهای انتخابی یافت نشد." />
) : (
</div>
) : (
<div className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
<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 className="border-b border-line bg-slate-bg/50 text-right text-muted font-medium">
<th className="w-8 py-2.5" />
<th className="py-2.5 pe-3">متریال</th>
<th className="py-2.5 pe-3">دسته‌بندی</th>
<th className="py-2.5 pe-3">انبار</th>
<th className="py-2.5 pe-3">مشخصه فنی</th>
<th className="py-2.5 pe-3">تعداد</th>
<th className="py-2.5 pe-3">واحد</th>
<th className="py-2.5 pe-3">آخرین به‌روزرسانی</th>
<th className="w-8 py-2.5" />
</tr>
</thead>
<tbody>
@@ -293,8 +337,9 @@ export function InventoryPage() {
})}
</tbody>
</table>
)}
</div>
</div>
)}
<div className="text-[11px] text-muted">
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها
</div>
@@ -317,25 +362,25 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
}) {
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">
<tr className={`border-b border-line/60 cursor-pointer transition-colors duration-150 ${isExp ? "bg-accent/5" : "hover:bg-slate-bg/60"}`} onClick={onToggle}>
<td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
<td className="py-2.5 pe-3 font-semibold text-ink">{matName}</td>
<td className="py-2.5 pe-3">{matCatName}</td>
<td className="py-2.5 pe-3">
{wh}
{isAdmin && <small className="block text-muted">{projName}</small>}
</td>
<td className="py-2 pe-3 text-muted">
<td className="py-2.5 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>
<td className="py-2.5 pe-3 font-bold text-ink">{faNum(g.qty)}</td>
<td className="py-2.5 pe-3">{unit}</td>
<td className="py-2.5 pe-3 text-muted">{isoToJalaliStr(g.updatedAt)}</td>
<td className="py-2.5 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 className="border-b border-line/40 bg-accent-bg/10">
<td colSpan={9} className="p-3.5">{timeline}</td>
</tr>
)}
</>
@@ -367,7 +412,9 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
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"}`}
className={`flex min-w-[125px] flex-col gap-1 rounded-md border p-2.5 text-start transition-all duration-150 active:scale-[0.98] ${
sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-slate-300"
}`}
style={{ borderTop: `3px solid ${meta.color}` }}
>
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
@@ -383,9 +430,9 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
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 className="mt-2.5">
<div className="mb-1.5 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div>
<div className="flex gap-2 overflow-x-auto pb-1">{track(brandTxs(g, expBrand))}</div>
</div>
) : null;
@@ -395,7 +442,7 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
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}` }}>
<div className="mt-2.5 rounded-md border border-line bg-panel p-2.5 text-[12px] shadow-xs" 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>
@@ -422,14 +469,14 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
return (
<div>
<div className="mb-1 flex flex-wrap items-center gap-2 text-[12px]">
<div className="mb-1.5 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">
<span className="rounded bg-panel px-2 py-0.5 text-[10.5px] text-muted border border-line">
{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>
<span className="rounded bg-panel px-2 py-0.5 text-[10.5px] text-muted border border-line">{faNum(g.brandOrder.length)} برند</span>
</div>
<div className="mb-2 flex flex-wrap gap-1.5">
{g.brandOrder.map((bid) => (
@@ -437,15 +484,17 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
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"}`}
className={`rounded-md border px-2.5 py-1 text-[11.5px] transition-all duration-150 active:scale-[0.98] ${
expBrand === bid ? "border-accent bg-accent/10 font-semibold text-accent" : "border-line bg-panel hover:bg-slate-bg"
}`}
>
{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>
<div className="mb-1.5 mt-2.5 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div>
<div className="flex gap-2 overflow-x-auto pb-1">{track(txs)}</div>
{selDetail}
</div>
);
@@ -1,13 +1,7 @@
import { Link } from "react-router-dom";
import { cn } from "../../lib/utils";
import { Button } from "../../components/ui/button";
// ناوبری بالای صفحه در پنل انبار — فقط روی «داشبورد انبار» (هاب) دیده می‌شود؛
// خودِ «داشبورد انبار» دکمه ندارد (ریشه/هاب است) و زیرصفحه‌ها تب ندارند و
// فقط با «بازگشت به داشبورد» به هاب برمی‌گردند.
//
// - variant="pm" (مدیر پروژه): کنترل روزانه، انبارها، کالاهای در راه —
// ورود به «تغییر موجودی» از دکمه‌ی بالای هاب است.
// - variant="admin" (مدیر تدارکات/ادمین): کنترل روزانه، کالاهای در راه، سازمان‌دهی.
// ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاین‌سیستم دکمه‌ها
export function InventoryTopNav({
pathname,
variant = "pm",
@@ -27,19 +21,19 @@ export function InventoryTopNav({
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") },
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
];
return (
<>
{items.map((it) => (
<Link
<Button
key={it.to}
to={it.to}
className={cn(
"btn-line-file no-underline",
it.active && "border-accent bg-accent text-white hover:bg-accent-dark",
)}
asChild
variant={it.active ? "primary" : "slate"}
>
{it.label}
</Link>
<Link to={it.to} className="no-underline">
{it.label}
</Link>
</Button>
))}
</>
);