Compare commits
9
Commits
main
..
50f8d8e64e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50f8d8e64e | ||
|
|
3b032d0460 | ||
|
|
f263cfb9e3 | ||
|
|
4609940856 | ||
|
|
3c3880aec9 | ||
|
|
545b5dd473 | ||
|
|
7528cc3bbe | ||
|
|
b45a6cd033 | ||
|
|
f0e424316e |
@@ -19,6 +19,9 @@ frontend/.env
|
||||
v1.2 - Copy/
|
||||
inv dash/
|
||||
|
||||
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
|
||||
docker-compose.override.yml
|
||||
|
||||
# ---- Screenshots (root) ----
|
||||
*.png
|
||||
*.jpg
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: procurement-api
|
||||
depends_on:
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
required: true
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: true
|
||||
environment:
|
||||
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
|
||||
PROCUREMENT_CASDOOR_ADMIN_USER: admin
|
||||
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
|
||||
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
|
||||
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
|
||||
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026
|
||||
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
|
||||
PROCUREMENT_CASDOOR_ISSUER: https://${AUTH_HOST}
|
||||
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
|
||||
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
|
||||
PROCUREMENT_MINIO_ACCESS_KEY: minioadmin
|
||||
PROCUREMENT_MINIO_BUCKET: procurement-files
|
||||
PROCUREMENT_MINIO_ENDPOINT: minio:9000
|
||||
PROCUREMENT_MINIO_SECRET_KEY: minioadmin
|
||||
PROCUREMENT_MINIO_SECURE: "false"
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.api-redir.entrypoints: web
|
||||
traefik.http.routers.api-redir.middlewares: redirect-https@file
|
||||
traefik.http.routers.api-redir.rule: Host(`${API_HOST}`)
|
||||
traefik.http.routers.api.entrypoints: websecure
|
||||
traefik.http.routers.api.rule: Host(`${API_HOST}`)
|
||||
traefik.http.routers.api.tls: "true"
|
||||
traefik.http.routers.api.tls.certresolver: letsencrypt
|
||||
traefik.http.services.api.loadbalancer.server.port: "8000"
|
||||
networks:
|
||||
default: null
|
||||
traefik_network: null
|
||||
ports:
|
||||
- "8000"
|
||||
restart: unless-stopped
|
||||
|
||||
casdoor:
|
||||
container_name: procurement-casdoor
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: true
|
||||
environment:
|
||||
RUNNING_IN_DOCKER: "true"
|
||||
dataSourceName: "user=postgres password=postgres host=postgres port=5432 sslmode=disable dbname=casdoor"
|
||||
dbName: casdoor
|
||||
driverName: postgres
|
||||
origin: https://${AUTH_HOST}
|
||||
image: casbin/casdoor:latest
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.casdoor-redir.entrypoints: web
|
||||
traefik.http.routers.casdoor-redir.middlewares: redirect-https@file
|
||||
traefik.http.routers.casdoor-redir.rule: Host(`${AUTH_HOST}`)
|
||||
traefik.http.routers.casdoor.entrypoints: websecure
|
||||
traefik.http.routers.casdoor.rule: Host(`${AUTH_HOST}`)
|
||||
traefik.http.routers.casdoor.tls: "true"
|
||||
traefik.http.routers.casdoor.tls.certresolver: letsencrypt
|
||||
traefik.http.services.casdoor.loadbalancer.server.port: "8000"
|
||||
networks:
|
||||
default: null
|
||||
traefik_network: null
|
||||
ports:
|
||||
- "8081:8000"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
DEV_LOGIN_ENABLED: "true"
|
||||
container_name: procurement-frontend
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
required: true
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.frontend-redir.entrypoints: web
|
||||
traefik.http.routers.frontend-redir.middlewares: redirect-https@file
|
||||
traefik.http.routers.frontend-redir.rule: Host(`${APP_HOST}`)
|
||||
traefik.http.routers.frontend.entrypoints: websecure
|
||||
traefik.http.routers.frontend.rule: Host(`${APP_HOST}`)
|
||||
traefik.http.routers.frontend.tls: "true"
|
||||
traefik.http.routers.frontend.tls.certresolver: letsencrypt
|
||||
traefik.http.services.frontend.loadbalancer.server.port: "80"
|
||||
networks:
|
||||
default: null
|
||||
traefik_network: null
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
command:
|
||||
- server
|
||||
- /data
|
||||
- --console-address
|
||||
- :9001
|
||||
container_name: procurement-minio
|
||||
environment:
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
image: minio/minio:latest
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.minio-api-redir.entrypoints: web
|
||||
traefik.http.routers.minio-api-redir.middlewares: redirect-https@file
|
||||
traefik.http.routers.minio-api-redir.rule: Host(`${S3_HOST}`)
|
||||
traefik.http.routers.minio-api.entrypoints: websecure
|
||||
traefik.http.routers.minio-api.rule: Host(`${S3_HOST}`)
|
||||
traefik.http.routers.minio-api.service: minio-api-svc
|
||||
traefik.http.routers.minio-api.tls: "true"
|
||||
traefik.http.routers.minio-api.tls.certresolver: letsencrypt
|
||||
traefik.http.routers.minio-console-redir.entrypoints: web
|
||||
traefik.http.routers.minio-console-redir.middlewares: redirect-https@file
|
||||
traefik.http.routers.minio-console-redir.rule: Host(`${STORAGE_HOST}`)
|
||||
traefik.http.routers.minio-console.entrypoints: websecure
|
||||
traefik.http.routers.minio-console.rule: Host(`${STORAGE_HOST}`)
|
||||
traefik.http.routers.minio-console.service: minio-console-svc
|
||||
traefik.http.routers.minio-console.tls: "true"
|
||||
traefik.http.routers.minio-console.tls.certresolver: letsencrypt
|
||||
traefik.http.services.minio-api-svc.loadbalancer.server.port: "9000"
|
||||
traefik.http.services.minio-console-svc.loadbalancer.server.port: "9001"
|
||||
networks:
|
||||
default: null
|
||||
traefik_network: null
|
||||
ports:
|
||||
- "9000"
|
||||
- "9001"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
minio-init:
|
||||
container_name: procurement-minio-init
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_started
|
||||
required: true
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
until mc alias set local http://$${MINIO_ENDPOINT} $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do
|
||||
echo 'waiting for minio server...';
|
||||
sleep 2;
|
||||
done;
|
||||
mc mb --ignore-existing local/$${MINIO_BUCKET};
|
||||
echo bucket $${MINIO_BUCKET} ready;
|
||||
environment:
|
||||
MINIO_BUCKET: procurement-files
|
||||
MINIO_ENDPOINT: minio:9000
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
image: minio/mc:latest
|
||||
networks:
|
||||
default: null
|
||||
restart: 'no'
|
||||
|
||||
postgres:
|
||||
container_name: procurement-postgres
|
||||
environment:
|
||||
POSTGRES_DB: procurement
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- pg_isready -U postgres -d procurement
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 10
|
||||
image: postgres:18
|
||||
networks:
|
||||
default: null
|
||||
ports:
|
||||
- "5433:5432"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
- ./sql/migration.sql:/docker-entrypoint-initdb.d/01-migration.sql:ro
|
||||
- ./sql/02-casdoor-db.sql:/docker-entrypoint-initdb.d/02-casdoor-db.sql:ro
|
||||
|
||||
traefik:
|
||||
command:
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.network=traefik_network
|
||||
- --providers.file.filename=/etc/traefik/dynamic.yml
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${TRAEFIK_ACME_EMAIL}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/certs/acme.json
|
||||
- --certificatesresolvers.letsencrypt.acme.httpchallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
|
||||
- --log.level=INFO
|
||||
container_name: procurement-traefik
|
||||
environment:
|
||||
TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL}
|
||||
image: traefik:v3.6
|
||||
networks:
|
||||
traefik_network: null
|
||||
ports:
|
||||
- "80"
|
||||
- "443"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||
- traefik_certs:/certs
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: v15_default
|
||||
traefik_network:
|
||||
name: traefik_network
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
minio_data:
|
||||
name: v15_minio_data
|
||||
postgres_data:
|
||||
name: v15_postgres_data
|
||||
traefik_certs:
|
||||
name: v15_traefik_certs
|
||||
@@ -1,7 +1,6 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { useAuthStore } from "./store/auth";
|
||||
import { MobileTabBar } from "./components/layout/MobileTabBar";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
|
||||
// هر صفحه با React.lazy لود میشود (code splitting)
|
||||
@@ -197,7 +196,6 @@ export default function App() {
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<MobileTabBar />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,6 @@ export function PageLayout({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4 max-md:pb-24", className)}>{children}</div>
|
||||
<div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Boxes,
|
||||
Building2,
|
||||
CalendarDays,
|
||||
LayoutDashboard,
|
||||
PackageSearch,
|
||||
Scale,
|
||||
Truck,
|
||||
UsersRound,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
|
||||
interface TabDef {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
// تبهای دفتر تدارکات (ادمین / مدیر تدارکات)
|
||||
const PROC_TABS: TabDef[] = [
|
||||
{ to: "/", label: "داشبورد", icon: LayoutDashboard, end: true },
|
||||
{ to: "/suppliers", label: "تأمینکنندهها", icon: Truck },
|
||||
{ to: "/projects", label: "پروژهها", icon: Building2 },
|
||||
{ to: "/units", label: "ضرایب", icon: Scale },
|
||||
];
|
||||
|
||||
// تبهای پنل انبار برای مدیر پروژه
|
||||
const PM_INV_TABS: TabDef[] = [
|
||||
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
|
||||
{ to: "/inventory/change", label: "تغییر موجودی", icon: ArrowLeftRight },
|
||||
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
|
||||
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
||||
];
|
||||
|
||||
// تبهای پنل انبار برای ادمین / مدیر تدارکات (آینهی ناوبری بالای دسکتاپ)
|
||||
const ADMIN_INV_TABS: TabDef[] = [
|
||||
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
|
||||
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
|
||||
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
||||
{ to: "/inventory/org", label: "سازماندهی", icon: UsersRound },
|
||||
];
|
||||
|
||||
function TabBar({ tabs }: { tabs: TabDef[] }) {
|
||||
return (
|
||||
<nav
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-line bg-panel md:hidden"
|
||||
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
|
||||
>
|
||||
<div className="grid grid-cols-4">
|
||||
{tabs.map((t) => (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex min-h-[56px] flex-col items-center justify-center gap-0.5 whitespace-nowrap px-1 text-[10.5px] font-medium transition-colors",
|
||||
isActive ? "text-accent" : "text-muted",
|
||||
)
|
||||
}
|
||||
>
|
||||
<t.icon className="h-5 w-5" strokeWidth={1.8} />
|
||||
<span>{t.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// نوار تب پایینِ موبایل — فقط زیر 768px (md:hidden) و فقط در صفحاتِ نقش کاربر:
|
||||
// مدیر پروژه → تبهای انبار؛ ادمین/مدیر تدارکات → تبهای دفتر یا انبار (بر اساس مسیر).
|
||||
// در ویزارد نمایش داده نمیشود تا با جابهجایی تصادفی، کارِ کاربر از بین نرود.
|
||||
export function MobileTabBar() {
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
if (pathname === "/login" || pathname === "/admin") return null;
|
||||
const isPM = role === "pm";
|
||||
const isOffice = role === "admin" || role === "procurement_manager";
|
||||
|
||||
if (pathname.startsWith("/inventory")) {
|
||||
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
|
||||
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
|
||||
return null;
|
||||
}
|
||||
if (pathname === "/wizard") return null;
|
||||
const onOfficePage =
|
||||
pathname === "/" ||
|
||||
pathname === "/projects" ||
|
||||
pathname === "/units" ||
|
||||
pathname.startsWith("/suppliers");
|
||||
if (onOfficePage && isOffice) return <TabBar tabs={PROC_TABS} />;
|
||||
return null;
|
||||
}
|
||||
@@ -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",
|
||||
@@ -44,4 +44,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button, buttonVariants };
|
||||
@@ -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,4 +140,4 @@ export function DashboardPage() {
|
||||
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,16 +484,18 @@ 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,20 +21,20 @@ 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -294,11 +294,11 @@ export function OrganizationPage() {
|
||||
<div className="mb-2 flex flex-col gap-1.5">
|
||||
{whs.map((w) => (
|
||||
<div key={w.id} className="flex items-center justify-between gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<b>{w.name}</b>
|
||||
<small className="block truncate text-muted">{w.description || ""}</small>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<button
|
||||
className="rounded-lg border border-accent/40 bg-accent/5 px-2 py-1 text-[10.5px] font-bold text-accent"
|
||||
onClick={() => navigate(`/inventory?warehouse=${w.id}`)}
|
||||
@@ -322,20 +322,20 @@ export function OrganizationPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-44"
|
||||
placeholder="نام انبار جدید"
|
||||
value={newWh[p.id] || ""}
|
||||
onChange={(e) => setNewWh((s) => ({ ...s, [p.id]: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="h-8 w-52 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-52"
|
||||
placeholder="توضیحات (اختیاری)"
|
||||
value={newWhDesc[p.id] || ""}
|
||||
onChange={(e) => setNewWhDesc((s) => ({ ...s, [p.id]: e.target.value }))}
|
||||
/>
|
||||
<button className="rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-white" onClick={() => void addWarehouse(p.id)}>
|
||||
<button className="w-full rounded-lg bg-accent px-3 py-2 text-[12px] font-bold text-white sm:w-auto sm:py-1.5" onClick={() => void addWarehouse(p.id)}>
|
||||
+ افزودن انبار
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user