Compare commits
12 Commits
fa49ba8817
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e4d9c5b59 | ||
|
|
0658e59d9b | ||
|
|
b27da3f6e8 | ||
|
|
c5e4ade2fb | ||
|
|
2d05d3446c | ||
|
|
55b03ad6f4 | ||
|
|
17dc53fcfe | ||
|
|
0befda9690 | ||
|
|
a1a34cb251 | ||
|
|
b2dcb45606 | ||
|
|
b6dc2c709e | ||
|
|
e42252db47 |
101
README.md
101
README.md
@@ -1,72 +1,75 @@
|
|||||||
# Slash.sa — منصة إدارة الأسطول واللوجستيات
|
# Slash.sa — Fleet & Logistics Management Platform
|
||||||
|
|
||||||
منصة ويب متكاملة لإدارة عمليات الأسطول واللوجستيات، مبنية بلغة عربية أولًا (Arabic-first)
|
A full-featured web platform for managing fleet and logistics operations, built **Arabic-first** with complete **RTL** support and targeted at the Saudi market. The platform covers vehicle, driver, branch, order, and trip management along with a maintenance log, all backed by role-based dashboards (admin / client / field user).
|
||||||
باتجاه RTL كامل، وموجهة للسوق السعودي. تشمل المنصة إدارة المركبات والسائقين والفروع
|
|
||||||
والطلبات والرحلات وسجل الصيانة، مع لوحات تحكم منفصلة حسب دور المستخدم (مشرف / عميل / مستخدم ميداني).
|
|
||||||
|
|
||||||
## المتطلبات الأساسية
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js 20.9 أو أحدث
|
- Node.js 20.9 or later
|
||||||
- npm (أو أي package manager متوافق)
|
- npm (or any compatible package manager)
|
||||||
|
|
||||||
## التقنيات المستخدمة
|
## Tech Stack
|
||||||
|
|
||||||
| الطبقة | التقنية |
|
| Layer | Technology |
|
||||||
| -------------------- | ------------------------------------------ |
|
| ---------------- | -------------------------------------------------------- |
|
||||||
| الإطار | Next.js 16 (App Router) |
|
| Framework | Next.js 16 (App Router) |
|
||||||
| اللغة | TypeScript |
|
| Language | TypeScript |
|
||||||
| التنسيق | Tailwind CSS v4 |
|
| Styling | Tailwind CSS v4 |
|
||||||
| النماذج والتحقق | react-hook-form + yup |
|
| Forms & Validation | react-hook-form + yup |
|
||||||
| الأيقونات | lucide-react (صفحات التسويق) / Tabler Icons Webfont (لوحة التحكم) |
|
| Icons | lucide-react (marketing pages) / Tabler Icons Webfont (dashboard) |
|
||||||
| المصادقة | JWT + HttpOnly cookie + middleware للحماية |
|
| Authentication | JWT + HttpOnly cookie + protective middleware |
|
||||||
|
|
||||||
## هيكل المشروع (مختصر)
|
## Project Structure (overview)
|
||||||
|
|
||||||
|
```
|
||||||
app/
|
app/
|
||||||
├── (صفحات تسويقية) home, features/app, how_work, prices, frequently_asked_questions
|
├── (marketing pages) home, features/app, how_it_works, pricing, faq
|
||||||
├── dashboard/ لوحة التحكم (محمية بواسطة middleware)
|
├── dashboard/ protected dashboard (guarded by middleware)
|
||||||
│ ├── cars, drivers, orders, trips, clients, branches, roles, audit, users
|
│ ├── cars, drivers, orders, trips, clients, branches, roles, audit, users
|
||||||
├── login, register صفحات المصادقة
|
├── login, register authentication pages
|
||||||
├── api/proxy/[...path] بروكسي موحّد لكل طلبات الـ backend
|
├── api/proxy/[...path] unified proxy for all backend requests
|
||||||
└── components/layout Navbar, Sidebar, Topbar (تُستخدم عبر الصفحات يدويًا)
|
└── components/layout Navbar, Sidebar, Topbar (manually used across pages)
|
||||||
|
|
||||||
src/
|
src/
|
||||||
├── Components/ مكوّنات لوحة التحكم (مقسّمة حسب الوحدة: Car, Driver, Order...)
|
├── Components/ dashboard components (organized per module: Car, Driver, Order...)
|
||||||
├── hooks/ منطق جلب البيانات والطفرات (Create/Update/Delete) لكل وحدة
|
├── hooks/ data-fetching and mutation logic (Create/Update/Delete) per module
|
||||||
├── services/ طبقة استدعاء الـ API (كل وحدة لها service منفصل)
|
├── services/ API call layer (each module has its own service)
|
||||||
├── types/ تعريفات TypeScript لكل نموذج بيانات
|
├── types/ TypeScript type definitions per data model
|
||||||
├── validations/ مخططات yup للتحقق من صحة النماذج
|
├── validations/ yup schemas for form validation
|
||||||
└── lib/ دوال مساعدة (auth, session, formatters, order-status)
|
└── lib/ helper functions (auth, session, formatters, order-status)
|
||||||
## التشغيل محليًا
|
```
|
||||||
|
|
||||||
|
## Running Locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
افتح [http://localhost:3000](http://localhost:3000) في المتصفح.
|
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
## الأوامر المتاحة
|
## Available Scripts
|
||||||
|
|
||||||
| الأمر | الوصف |
|
| Command | Description |
|
||||||
| ---------------- | --------------------------------------- |
|
| --------------- | ------------------------------------- |
|
||||||
| `npm run dev` | تشغيل بيئة التطوير (Webpack) |
|
| `npm run dev` | Run the development server (Webpack) |
|
||||||
| `npm run build` | بناء نسخة الإنتاج |
|
| `npm run build` | Build the production bundle |
|
||||||
| `npm run start` | تشغيل نسخة الإنتاج المبنية |
|
| `npm run start` | Run the built production version |
|
||||||
| `npm run lint` | فحص الكود بواسطة ESLint |
|
| `npm run lint` | Lint the codebase with ESLint |
|
||||||
|
|
||||||
## ملاحظات النشر (Deployment)
|
## Deployment Notes
|
||||||
|
|
||||||
- المنصة مبنية على Next.js App Router وتعمل على أي بيئة تدعم Node.js 20.9+ (مثل Vercel).
|
- The platform is built on the Next.js App Router and runs on any environment that supports Node.js 20.9+ (e.g., Vercel).
|
||||||
- كل طلبات الـ backend تمر عبر `app/api/proxy/[...path]/route.ts`، فتأكد من ضبط الـ backend base URL بشكل صحيح قبل النشر.
|
- All backend requests are routed through `app/api/proxy/[...path]/route.ts` — make sure the backend base URL is configured correctly before deploying.
|
||||||
- التوثيق (Authentication) يعتمد على HttpOnly cookie تُضبط عبر `app/api/auth/set-cookie`، وتُمسح عبر `app/api/clear-cookie` — تأكد من تفعيل `Secure` في بيئة الإنتاج (يتم تلقائيًا حسب `NODE_ENV`).
|
- Authentication relies on an HttpOnly cookie set via `app/api/auth/set-cookie` and cleared via `app/api/clear-cookie`. Make sure `Secure` is enabled in production (this happens automatically based on `NODE_ENV`).
|
||||||
- الحماية على مستوى الراوت تتم عبر `middleware.ts` — راجع الـ `matcher` فيه عند إضافة مسارات محمية جديدة.
|
- Route-level protection is handled by `middleware.ts` — check its `matcher` config whenever you add new protected routes.
|
||||||
|
|
||||||
## المساهمة (Contributing)
|
## Contributing
|
||||||
|
|
||||||
1. أنشئ فرعًا جديدًا من `main` باسم واضح للتغيير (مثال: `feature/car-maintenance-filters`).
|
1. Create a new branch from `main` with a clear name describing the change (e.g., `feature/car-maintenance-filters`).
|
||||||
2. اتبع نفس أسلوب الكود الموجود: RTL-first، CSS custom property tokens بدل الألوان الثابتة، وهيكلة hooks/services/types/validations لكل وحدة جديدة.
|
2. Follow the existing code conventions: RTL-first, CSS custom property tokens instead of hardcoded colors, and the hooks/services/types/validations structure for each new module.
|
||||||
3. شغّل `npm run lint` قبل فتح Pull Request.
|
3. Run `npm run lint` before opening a Pull Request.
|
||||||
4. اكتب وصفًا واضحًا للتغيير وربطه بأي تذكرة (issue) ذات صلة.
|
4. Write a clear description of the change and link it to any related issue.
|
||||||
|
|
||||||
## الترخيص
|
## License
|
||||||
|
|
||||||
جميع الحقوق محفوظة © 2026 Slash.sa
|
All rights reserved © 2026 Slash.sa
|
||||||
@@ -8,18 +8,13 @@ import Navbar from "./Navbar";
|
|||||||
export default function ConditionalNavbar() {
|
export default function ConditionalNavbar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// استبدلنا getStoredUser() المباشر بالـ hook عشان يبقى SSR-safe
|
|
||||||
const { user, loading } = useStoredUser();
|
const { user, loading } = useStoredUser();
|
||||||
|
|
||||||
// لحد ما نتأكد من حالة تسجيل الدخول من الـ storage، منوريش/مخفيش
|
|
||||||
// الناف بار عشان نتجنب وميض (flash) قبل الـ hydration يخلص. مفيش
|
|
||||||
// حاجة تتعرض هنا أصلاً (مفيش avatar/اسم في الناف بار العام)، فـ null
|
|
||||||
// كافية ومفيش داعي لـ Spinner/InlineLoader في المكان ده.
|
|
||||||
if (loading) return null;
|
if (loading) return null;
|
||||||
|
|
||||||
// لو المستخدم مسجل دخول، أو الصفحة الحالية جوه الداشبورد، منظهرش الناف بار العام
|
|
||||||
const isDashboardRoute = pathname?.startsWith("/dashboard");
|
const isDashboardRoute = pathname?.startsWith("/dashboard");
|
||||||
if (user || isDashboardRoute) return null;
|
if (user || isDashboardRoute) return null;
|
||||||
|
|
||||||
return <Navbar />;
|
return "";
|
||||||
}
|
}
|
||||||
@@ -288,17 +288,7 @@ export function Sidebar() {
|
|||||||
color: "#fff",
|
color: "#fff",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<i className="ti ti-x" style={{ fontSize: 13 }} aria-hidden="true" />
|
||||||
width="13"
|
|
||||||
height="13"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
>
|
|
||||||
<path d="M18 6 6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
@@ -335,7 +325,7 @@ function SidebarContent({
|
|||||||
justifyContent: compact ? "center" : "flex-start",
|
justifyContent: compact ? "center" : "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{compact ? <BrandIcon size={36} /> : <Logo white />}
|
{compact ? <BrandIcon size={36} /> : <Logo white href="/dashboard" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ export function Topbar() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { setOpen } = useSidebarDrawer();
|
const { setOpen } = useSidebarDrawer();
|
||||||
|
|
||||||
// كان بيتقرأ بشكل sync عن طريق getStoredUser() جوه الرندر — استبدلناه بالـ hook
|
|
||||||
// عشان يبقى SSR-safe ومايعملش hydration mismatch، وكمان يعمل subscribe
|
|
||||||
// لأي تغيير في الـ storage (تسجيل دخول/خروج من تاب تاني).
|
|
||||||
const { user, loading } = useStoredUser();
|
const { user, loading } = useStoredUser();
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
|
|||||||
74
app/components/system/ExtensionAttributeCleanup.tsx
Normal file
74
app/components/system/ExtensionAttributeCleanup.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// Some browser extensions (Bitdefender TrafficLight and similar) inject
|
||||||
|
// bis_skin_checked / bis_register attributes onto form elements before
|
||||||
|
// React hydrates the page. This causes a harmless but noisy hydration
|
||||||
|
// mismatch warning. This component strips those attributes after mount
|
||||||
|
// and keeps watching for re-injection, without disabling SSR/hydration.
|
||||||
|
export const WATCHED_ATTRS = [
|
||||||
|
"bis_skin_checked",
|
||||||
|
"bis_register",
|
||||||
|
"__processed_by_bitdefender__",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ExtensionAttributeCleanup() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined" || typeof MutationObserver === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selector = WATCHED_ATTRS.map((attr) => `[${attr}]`).join(",");
|
||||||
|
|
||||||
|
function strip() {
|
||||||
|
const rootElements = [document.documentElement, document.body].filter(
|
||||||
|
(element): element is HTMLElement => element !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
rootElements.forEach((element) => {
|
||||||
|
WATCHED_ATTRS.forEach((attr) => element.removeAttribute(attr));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(selector).forEach(el => {
|
||||||
|
WATCHED_ATTRS.forEach(attr => el.removeAttribute(attr));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameId: number | null = null;
|
||||||
|
function scheduleStrip() {
|
||||||
|
if (frameId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
frameId = window.requestAnimationFrame(() => {
|
||||||
|
frameId = null;
|
||||||
|
strip();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial cleanup right after hydration
|
||||||
|
strip();
|
||||||
|
|
||||||
|
// Extensions can re-inject the attribute after every re-render
|
||||||
|
// (e.g. after clicking Save, which re-renders the form), so keep
|
||||||
|
// watching instead of running once.
|
||||||
|
const observer = new MutationObserver(scheduleStrip);
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
subtree: true,
|
||||||
|
attributeFilter: [...WATCHED_ATTRS],
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
if (frameId !== null) {
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Renders nothing — side-effect-only component
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,25 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useReducer, useState } from "react";
|
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||||
import { Alert, Spinner } from "@/src/Components/UI";
|
import { Alert } from "@/src/Components/UI";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { get } from "@/src/services/api";
|
import { get } from "@/src/services/api";
|
||||||
import Header from "@/src/Components/UI/Header";
|
import Header from "@/src/Components/UI/Header";
|
||||||
|
import { AuditTable } from "@/src/Components/audit/AuditTable";
|
||||||
|
import { AuditDetailModal } from "@/src/Components/audit/AuditDetailsModel";
|
||||||
|
import { AuditLog } from "@/src/types/audit";
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
// (AuditLog itself now lives in @/src/types/audit so AuditTable and
|
||||||
interface AuditLog {
|
// AuditDetailModal can both import it — same pattern as Driver/Order.)
|
||||||
id: string;
|
|
||||||
action: string;
|
|
||||||
module?: string | null;
|
|
||||||
entityId?: string | null;
|
|
||||||
userId?: string | null;
|
|
||||||
userName?: string | null;
|
|
||||||
ipAddress?: string | null;
|
|
||||||
userAgent?: string | null;
|
|
||||||
metadata?: Record<string, unknown> | null;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── State / Reducer ────────────────────────────────────────────────────────
|
// ── State / Reducer ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -58,44 +50,6 @@ function reducer(s: State, a: Action): State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Action badge ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const ACTION_COLORS: Record<
|
|
||||||
string,
|
|
||||||
{ bg: string; color: string; border: string }
|
|
||||||
> = {
|
|
||||||
CREATE: { bg: "#DCFCE7", color: "#166534", border: "#BBF7D0" },
|
|
||||||
UPDATE: { bg: "#EFF6FF", color: "#1D4ED8", border: "#BFDBFE" },
|
|
||||||
DELETE: { bg: "#FEF2F2", color: "#DC2626", border: "#FECACA" },
|
|
||||||
LOGIN: { bg: "#F5F3FF", color: "#5B21B6", border: "#DDD6FE" },
|
|
||||||
LOGOUT: { bg: "#F1F5F9", color: "#475569", border: "#E2E8F0" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function ActionBadge({ action }: { action: string }) {
|
|
||||||
const upper = action?.toUpperCase() ?? "";
|
|
||||||
const key =
|
|
||||||
Object.keys(ACTION_COLORS).find((k) => upper.includes(k)) ?? "CREATE";
|
|
||||||
const cfg = ACTION_COLORS[key];
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
borderRadius: "var(--radius-full)",
|
|
||||||
border: `1px solid ${cfg.border}`,
|
|
||||||
background: cfg.bg,
|
|
||||||
padding: "0.2rem 0.625rem",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: cfg.color,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{action}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Page ───────────────────────────────────────────────────────────────────
|
// ── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AuditPage() {
|
export default function AuditPage() {
|
||||||
@@ -109,12 +63,18 @@ export default function AuditPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [module, setModule] = useState("");
|
const [module, setModule] = useState("");
|
||||||
|
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||||
|
|
||||||
const loadLogs = useCallback(async (p: number, q: string, mod: string) => {
|
const loadLogs = useCallback(async (q: string, mod: string) => {
|
||||||
dispatch({ type: "LOAD_START" });
|
dispatch({ type: "LOAD_START" });
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const params = new URLSearchParams({ page: String(p), limit: "15" });
|
// Show ALL matching logs at once instead of paginating — a high
|
||||||
|
// limit stands in for "no limit" against an endpoint that still
|
||||||
|
// expects page/limit params. If the backend enforces its own hard
|
||||||
|
// cap below this number, ask it to expose an explicit "all" mode
|
||||||
|
// instead of relying on this number being high enough.
|
||||||
|
const params = new URLSearchParams({ page: "1", limit: "1000" });
|
||||||
if (q) params.set("search", q);
|
if (q) params.set("search", q);
|
||||||
if (mod) params.set("module", mod);
|
if (mod) params.set("module", mod);
|
||||||
const res = await get<{
|
const res = await get<{
|
||||||
@@ -148,32 +108,14 @@ export default function AuditPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLogs(page, search, module);
|
loadLogs(search, module);
|
||||||
}, [page, search, module, loadLogs]);
|
}, [search, module, loadLogs]);
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
overflow: "hidden",
|
|
||||||
boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<>
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
|
<section
|
||||||
>
|
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Header
|
<Header
|
||||||
state={state}
|
state={state}
|
||||||
@@ -197,168 +139,23 @@ export default function AuditPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div style={cardStyle}>
|
<AuditTable
|
||||||
<div
|
logs={state.logs}
|
||||||
dir="rtl"
|
loading={state.loading}
|
||||||
style={{
|
search={search}
|
||||||
display: "grid",
|
page={page}
|
||||||
gridTemplateColumns: "1.5fr 1fr 1fr 1.5fr 1fr",
|
pages={state.pages}
|
||||||
...thStyle,
|
onPageChange={setPage}
|
||||||
}}
|
onRowClick={(log) => setSelectedLog(log)}
|
||||||
>
|
/>
|
||||||
<span>الإجراء</span>
|
|
||||||
<span>الوحدة</span>
|
|
||||||
<span>المستخدم</span>
|
|
||||||
<span>عنوان IP</span>
|
|
||||||
<span style={{ textAlign: "center" }}>الوقت</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state.loading ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 12,
|
|
||||||
padding: "4rem 0",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
|
||||||
</div>
|
|
||||||
) : state.logs.length === 0 ? (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
padding: "4rem 0",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
لا توجد سجلات مطابقة
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
|
||||||
{state.logs.map((log, i) => (
|
|
||||||
<li
|
|
||||||
key={log.id}
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1.5fr 1fr 1fr 1.5fr 1fr",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.5rem",
|
|
||||||
padding: "0.875rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background:
|
|
||||||
i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<ActionBadge action={log.action} />
|
|
||||||
{log.entityId && (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
marginTop: 2,
|
|
||||||
fontFamily: "var(--font-mono)",
|
|
||||||
fontSize: 10,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{log.entityId.slice(0, 12)}…
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
|
||||||
{log.module ?? "—"}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
|
||||||
{log.userName ?? log.userId ?? "—"}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-mono)",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{log.ipAddress ?? "—"}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{new Date(log.createdAt).toLocaleString("ar-SA", {
|
|
||||||
dateStyle: "short",
|
|
||||||
timeStyle: "short",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state.pages > 1 && (
|
|
||||||
<div
|
|
||||||
dir="rtl"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
borderTop: "1px solid var(--color-border)",
|
|
||||||
padding: "0.875rem 1.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
صفحة{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
|
||||||
{page}
|
|
||||||
</strong>{" "}
|
|
||||||
من{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
|
||||||
{state.pages}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
label: "السابق",
|
|
||||||
action: () => setPage((p) => Math.max(1, p - 1)),
|
|
||||||
disabled: page === 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "التالي",
|
|
||||||
action: () => setPage((p) => Math.min(state.pages, p + 1)),
|
|
||||||
disabled: page === state.pages,
|
|
||||||
},
|
|
||||||
].map((btn) => (
|
|
||||||
<button
|
|
||||||
key={btn.label}
|
|
||||||
onClick={btn.action}
|
|
||||||
disabled={btn.disabled}
|
|
||||||
style={{
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface-muted)",
|
|
||||||
padding: "0.375rem 0.875rem",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
cursor: btn.disabled ? "not-allowed" : "pointer",
|
|
||||||
opacity: btn.disabled ? 0.4 : 1,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{btn.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{selectedLog && (
|
||||||
|
<AuditDetailModal
|
||||||
|
log={selectedLog}
|
||||||
|
onClose={() => setSelectedLog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Alert, PageHeader, Spinner } from "@/src/Components/UI";
|
import { Alert, ArchiveButton, Button, ConfirmDialog, EmptyState, PageHeader, Spinner, Toast } from "@/src/Components/UI";
|
||||||
import { useCarMaintenanceList } from "@/src/hooks/UseCarsMaintanance";
|
import { CarMaintenanceFormModal } from "@/src/Components/Car_Maintanance/CarMaintananceFormModal";
|
||||||
|
import { ArchivedCarsMaintenanceModal } from "@/src/Components/Car_Maintanance/archive/ArchivedCarsMaintananceModal";
|
||||||
|
import {
|
||||||
|
useCarMaintenanceList,
|
||||||
|
useCarMaintenanceMutations,
|
||||||
|
useMaintenanceToast,
|
||||||
|
} from "@/src/hooks/UseCarsMaintanance";
|
||||||
|
import { useCarDetail } from "@/src/hooks/useCars";
|
||||||
import {
|
import {
|
||||||
MAINTENANCE_STATUS_MAP,
|
MAINTENANCE_STATUS_MAP,
|
||||||
fmtDate,
|
fmtDate,
|
||||||
@@ -10,92 +18,183 @@ import {
|
|||||||
durationDays,
|
durationDays,
|
||||||
getMaintenanceStatus,
|
getMaintenanceStatus,
|
||||||
} from "@/src/types/carMaintanance";
|
} from "@/src/types/carMaintanance";
|
||||||
|
import type {
|
||||||
|
CarMaintenance,
|
||||||
|
CreateMaintenancePayload,
|
||||||
|
UpdateMaintenancePayload,
|
||||||
|
} from "@/src/types/carMaintanance";
|
||||||
|
|
||||||
|
function MaintenanceCard({
|
||||||
|
record, onView, onEdit, onDelete,
|
||||||
|
}: {
|
||||||
|
record: CarMaintenance;
|
||||||
|
onView: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)];
|
||||||
|
|
||||||
function DetailRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<article style={{
|
||||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
||||||
padding: "0.75rem 0", borderBottom: "1px solid var(--color-border)",
|
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>{label}</span>
|
<div style={{ height: 4, background: status.dot }} />
|
||||||
<span style={{ fontSize: 14, fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)", color: "var(--color-text-primary)" }}>
|
<div style={{ padding: "1.25rem" }}>
|
||||||
{value}
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
|
||||||
</span>
|
<p style={{ fontSize: 15, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
</div>
|
{record.reason}
|
||||||
|
</p>
|
||||||
|
<span style={{
|
||||||
|
borderRadius: "var(--radius-full)", border: `1px solid ${status.border}`,
|
||||||
|
background: status.bg, padding: "0.2rem 0.625rem",
|
||||||
|
fontSize: 11, fontWeight: 700, color: status.color, whiteSpace: "nowrap", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "0.875rem", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}>
|
||||||
|
<div style={{ fontSize: 11 }}>
|
||||||
|
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>التكلفة</span>
|
||||||
|
<span style={{ color: "var(--color-text-primary)", marginTop: 2, display: "block" }}>{fmtCost(record.cost)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11 }}>
|
||||||
|
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>المدة</span>
|
||||||
|
<span style={{ color: "var(--color-text-primary)", marginTop: 2, display: "block" }}>{durationDays(record.startAt, record.endAt)} يوم</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11 }}>
|
||||||
|
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>تاريخ البدء</span>
|
||||||
|
<span style={{ color: "var(--color-text-secondary)", marginTop: 2, display: "block" }}>{fmtDate(record.startAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11 }}>
|
||||||
|
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>تاريخ الانتهاء</span>
|
||||||
|
<span style={{ color: "var(--color-text-secondary)", marginTop: 2, display: "block" }}>{fmtDate(record.endAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", marginTop: "1rem" }}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={onEdit}>
|
||||||
|
تعديل
|
||||||
|
</Button>
|
||||||
|
{/* inline style keeps the original light-red look; Button's own
|
||||||
|
variant="danger" is solid red and would change the visual */}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDelete}
|
||||||
|
style={{ background: "#FEF2F2", borderColor: "#FECACA", color: "#DC2626" }}
|
||||||
|
>
|
||||||
|
أرشفة
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CarMaintenanceDetailPage() {
|
export default function CarMaintenancePage() {
|
||||||
const { carId, maintenanceId } = useParams<{ carId: string; maintenanceId: string }>();
|
const { carId } = useParams<{ carId: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// NOTE: GET /cars/:carId/maintenance/:maintenanceId isn't wired up on the
|
const { car, refetch: refetchCar } = useCarDetail(carId);
|
||||||
// backend yet (returns "Route not found"), so this page resolves the
|
const carLabel = car ? `${car.manufacturer} ${car.model} — ${car.plateLetters} ${car.plateNumber}` : undefined;
|
||||||
// record from the already-loaded list instead of a dedicated single-record
|
|
||||||
// call. includeDeleted: true so an archived record still resolves to a
|
const { records, loading, error, loadRecords, removeRecord, setError } = useCarMaintenanceList(carId);
|
||||||
// real "مؤرشفة" status instead of silently disappearing.
|
const { toast, notify } = useMaintenanceToast();
|
||||||
const { records, loading, error } = useCarMaintenanceList(carId, { includeDeleted: true });
|
|
||||||
const record = records.find((r) => r.id === maintenanceId) ?? null;
|
const [formTarget, setFormTarget] = useState<CarMaintenance | null | false>(false); // false = closed
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<CarMaintenance | null>(null);
|
||||||
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
|
|
||||||
|
const getEditTarget = useCallback(
|
||||||
|
() => (formTarget instanceof Object && formTarget !== null ? (formTarget as CarMaintenance) : null),
|
||||||
|
[formTarget],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { deleting, handleFormSubmit, handleDeleteConfirm } = useCarMaintenanceMutations({
|
||||||
|
carId,
|
||||||
|
onSuccess: (msg) => { notify({ type: "success", message: msg }); loadRecords(); refetchCar(); },
|
||||||
|
onError: (msg) => notify({ type: "error", message: msg }),
|
||||||
|
onDeleted: (id) => { removeRecord(id); setDeleteTarget(null); refetchCar(); },
|
||||||
|
getEditTarget,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem", maxWidth: 720, margin: "0 auto" }} dir="rtl">
|
<>
|
||||||
<PageHeader
|
<Toast notification={toast} />
|
||||||
title="تفاصيل سجل الصيانة"
|
|
||||||
description={record?.reason}
|
{formTarget !== false && (
|
||||||
backHref={`/dashboard/cars/${carId}/maintenance`}
|
<CarMaintenanceFormModal
|
||||||
backLabel="العودة"
|
editRecord={formTarget}
|
||||||
|
carLabel={carLabel}
|
||||||
|
onClose={() => setFormTarget(false)}
|
||||||
|
onSubmit={(payload: CreateMaintenancePayload | UpdateMaintenancePayload, isNew: boolean) =>
|
||||||
|
handleFormSubmit(payload, isNew).then(ok => { if (ok) setFormTarget(false); return ok; })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
loading={deleting}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => deleteTarget && handleDeleteConfirm(deleteTarget)}
|
||||||
|
title="حذف سجل الصيانة"
|
||||||
|
description={`هل أنت متأكد من حذف سجل ${deleteTarget?.reason ?? ""} (${deleteTarget ? fmtCost(deleteTarget.cost) : ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{
|
{archiveOpen && (
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
<ArchivedCarsMaintenanceModal
|
||||||
background: "var(--color-surface)", boxShadow: "var(--shadow-card)", padding: "1.5rem",
|
carId={carId}
|
||||||
}}>
|
carLabel={carLabel}
|
||||||
{loading && (
|
onClose={() => setArchiveOpen(false)}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
|
/>
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
)}
|
||||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>جارٍ التحميل…</span>
|
|
||||||
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} dir="rtl">
|
||||||
|
<PageHeader
|
||||||
|
title="سجل الصيانة"
|
||||||
|
description={carLabel ?? "جارٍ تحميل بيانات المركبة…"}
|
||||||
|
backHref="/dashboard/cars"
|
||||||
|
backLabel="المركبات"
|
||||||
|
action={
|
||||||
|
<Button onClick={() => setFormTarget(null)}>
|
||||||
|
<i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
|
إضافة سجل صيانة
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "5rem 0", color: "var(--color-text-muted)" }}>
|
||||||
|
<Spinner size="md" className="text-blue-600" />
|
||||||
|
<span style={{ fontSize: 14 }}>جارٍ تحميل سجلات الصيانة…</span>
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="🔧"
|
||||||
|
title="لا توجد سجلات صيانة بعد"
|
||||||
|
description='اضغط على "إضافة سجل صيانة" لتسجيل أول عملية صيانة لهذه المركبة.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: "1rem" }}>
|
||||||
|
{records.map(record => (
|
||||||
|
<MaintenanceCard
|
||||||
|
key={record.id}
|
||||||
|
record={record}
|
||||||
|
onView={() => router.push(`/dashboard/cars/${carId}/maintenance/${record.id}`)}
|
||||||
|
onEdit={() => setFormTarget(record)}
|
||||||
|
onDelete={() => setDeleteTarget(record)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{error && <Alert type="error" message={error} />}
|
<ArchiveButton onClick={() => setArchiveOpen(true)} label="أرشيف الصيانة" />
|
||||||
|
</>
|
||||||
{!loading && !error && !record && (
|
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)", textAlign: "center", padding: "2rem 0" }}>
|
|
||||||
لم يتم العثور على سجل الصيانة.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && record && (() => {
|
|
||||||
const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)];
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}>
|
|
||||||
<span style={{ borderRadius: "var(--radius-full)", border: `1px solid ${status.border}`, background: status.bg, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: status.color }}>
|
|
||||||
{status.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DetailRow label="سبب الصيانة" value={record.reason} />
|
|
||||||
<DetailRow label="التكلفة" value={fmtCost(record.cost)} />
|
|
||||||
<DetailRow label="تاريخ البدء" value={fmtDate(record.startAt)} />
|
|
||||||
<DetailRow label="تاريخ الانتهاء" value={fmtDate(record.endAt)} />
|
|
||||||
<DetailRow label="المدة" value={`${durationDays(record.startAt, record.endAt)} يوم`} />
|
|
||||||
{record.car && (
|
|
||||||
<DetailRow label="المركبة" value={`${record.car.manufacturer} ${record.car.model} — ${record.car.plateLetters} ${record.car.plateNumber}`} mono />
|
|
||||||
)}
|
|
||||||
<DetailRow label="تاريخ الإضافة" value={fmtDate(record.createdAt)} />
|
|
||||||
<DetailRow label="آخر تحديث" value={fmtDate(record.updatedAt)} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button type="button" onClick={() => router.push(`/dashboard/cars/${carId}/maintenance`)}
|
|
||||||
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
|
|
||||||
العودة
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -166,9 +166,7 @@ export default function CarMaintenancePage() {
|
|||||||
display: "inline-flex", alignItems: "center", gap: 7,
|
display: "inline-flex", alignItems: "center", gap: 7,
|
||||||
fontFamily: "var(--font-sans)", whiteSpace: "nowrap",
|
fontFamily: "var(--font-sans)", whiteSpace: "nowrap",
|
||||||
}}>
|
}}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
إضافة سجل صيانة
|
إضافة سجل صيانة
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Spinner, Alert, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
import {
|
||||||
import { CarFormModal,CarDetailPanel } from "@/src/Components/car";
|
Spinner,
|
||||||
|
Alert,
|
||||||
|
ArchiveButton,
|
||||||
|
ConfirmDialog,
|
||||||
|
} from "@/src/Components/UI";
|
||||||
|
import { CarFormModal, CarDetailPanel } from "@/src/Components/car";
|
||||||
import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal";
|
import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal";
|
||||||
import { CarMaintenanceFormModal } from "@/src/Components/Car_Maintanance/CarMaintananceFormModal";
|
import { CarMaintenanceFormModal } from "@/src/Components/Car_Maintanance/CarMaintananceFormModal";
|
||||||
import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars";
|
import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars";
|
||||||
import { useCarMaintenanceMutations } from "@/src/hooks/UseCarsMaintanance";
|
import { useCarMaintenanceMutations } from "@/src/hooks/UseCarsMaintanance";
|
||||||
import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car";
|
import {
|
||||||
import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/src/types/car";
|
fmtDateShort,
|
||||||
import type { CreateMaintenancePayload, UpdateMaintenancePayload } from "@/src/types/carMaintanance";
|
isExpiringSoon,
|
||||||
import Header from "@/src/Components/UI/Header";
|
STATUS_MAP,
|
||||||
|
INS_MAP,
|
||||||
|
} from "@/src/types/car";
|
||||||
|
import type {
|
||||||
|
Car,
|
||||||
|
CreateCarPayload,
|
||||||
|
ToastMsg,
|
||||||
|
UpdateCarPayload,
|
||||||
|
} from "@/src/types/car";
|
||||||
|
import type {
|
||||||
|
CreateMaintenancePayload,
|
||||||
|
UpdateMaintenancePayload,
|
||||||
|
} from "@/src/types/carMaintanance";
|
||||||
|
|
||||||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -19,21 +35,35 @@ function CarToast({ notification }: { notification: ToastMsg | null }) {
|
|||||||
if (!notification) return null;
|
if (!notification) return null;
|
||||||
const ok = notification.type === "success";
|
const ok = notification.type === "success";
|
||||||
return (
|
return (
|
||||||
<div role="status" aria-live="polite" style={{
|
<div
|
||||||
position: "fixed", bottom: 24, left: "50%",
|
role="status"
|
||||||
transform: "translateX(-50%)",
|
aria-live="polite"
|
||||||
zIndex: 9999, pointerEvents: "none",
|
style={{
|
||||||
}}>
|
position: "fixed",
|
||||||
<div style={{
|
bottom: 24,
|
||||||
display: "flex", alignItems: "center", gap: 10,
|
left: "50%",
|
||||||
padding: "0.75rem 1.25rem",
|
transform: "translateX(-50%)",
|
||||||
borderRadius: "var(--radius-full)",
|
zIndex: 9999,
|
||||||
background: ok ? "#065F46" : "#7F1D1D",
|
pointerEvents: "none",
|
||||||
color: "#FFF", fontSize: 13, fontWeight: 600,
|
}}
|
||||||
boxShadow: "0 8px 32px rgba(0,0,0,.25)",
|
>
|
||||||
maxWidth: "90vw", whiteSpace: "nowrap",
|
<div
|
||||||
fontFamily: "var(--font-sans)",
|
style={{
|
||||||
}}>
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "0.75rem 1.25rem",
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
background: ok ? "#065F46" : "#7F1D1D",
|
||||||
|
color: "#FFF",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
boxShadow: "0 8px 32px rgba(0,0,0,.25)",
|
||||||
|
maxWidth: "90vw",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span style={{ fontSize: 16 }}>{ok ? "✓" : "⚠"}</span>
|
<span style={{ fontSize: 16 }}>{ok ? "✓" : "⚠"}</span>
|
||||||
<span>{notification.message}</span>
|
<span>{notification.message}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,6 +72,10 @@ function CarToast({ notification }: { notification: ToastMsg | null }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── CarCard ───────────────────────────────────────────────────────────────────
|
// ── CarCard ───────────────────────────────────────────────────────────────────
|
||||||
|
// NOTE: hover/lift affordance is implemented via Tailwind `hover:`/`focus-visible:`
|
||||||
|
// classes instead of onMouseEnter/onMouseLeave DOM mutation, so keyboard users
|
||||||
|
// (tabbing to this card, which is already role="button" tabIndex={0}) get the
|
||||||
|
// same visual feedback as mouse users via the native :focus-visible pseudo-class.
|
||||||
|
|
||||||
function CarCard({
|
function CarCard({
|
||||||
car,
|
car,
|
||||||
@@ -53,13 +87,14 @@ function CarCard({
|
|||||||
onSendToMaintenance: (car: Car) => void;
|
onSendToMaintenance: (car: Car) => void;
|
||||||
}) {
|
}) {
|
||||||
const status = STATUS_MAP[car.currentStatus];
|
const status = STATUS_MAP[car.currentStatus];
|
||||||
const ins = car.insuranceStatus ? INS_MAP[car.insuranceStatus] : null;
|
const ins = car.insuranceStatus ? INS_MAP[car.insuranceStatus] : null;
|
||||||
const regWarn = isExpiringSoon(car.registrationExpiryDate);
|
const regWarn = isExpiringSoon(car.registrationExpiryDate);
|
||||||
|
|
||||||
// Maintenance status can ONLY be set via this button's flow (POST
|
// Maintenance status can ONLY be set via this button's flow (POST
|
||||||
// cars/:carId/maintenance flips it on the backend). Disable rather than
|
// cars/:carId/maintenance flips it on the backend). Disable rather than
|
||||||
// hide it once the car is already in maintenance or inactive, and explain why.
|
// hide it once the car is already in maintenance or inactive, and explain why.
|
||||||
const maintenanceDisabled = car.currentStatus === "InMaintenance" || car.currentStatus === "Inactive";
|
const maintenanceDisabled =
|
||||||
|
car.currentStatus === "InMaintenance" || car.currentStatus === "Inactive";
|
||||||
const maintenanceDisabledReason =
|
const maintenanceDisabledReason =
|
||||||
car.currentStatus === "InMaintenance"
|
car.currentStatus === "InMaintenance"
|
||||||
? "المركبة في الصيانة بالفعل"
|
? "المركبة في الصيانة بالفعل"
|
||||||
@@ -72,101 +107,206 @@ function CarCard({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={e => { if (e.key === "Enter" || e.key === " ") onClick(); }}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") onClick();
|
||||||
|
}}
|
||||||
aria-label={`${car.manufacturer} ${car.model} — ${car.plateLetters} ${car.plateNumber}`}
|
aria-label={`${car.manufacturer} ${car.model} — ${car.plateLetters} ${car.plateNumber}`}
|
||||||
style={{
|
className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden shadow-[var(--shadow-card)] cursor-pointer outline-none transition-all duration-200 hover:shadow-[0_8px_24px_rgba(37,99,235,.12)] hover:-translate-y-0.5 focus-visible:shadow-[0_8px_24px_rgba(37,99,235,.12)] focus-visible:-translate-y-0.5 focus-visible:ring-2 focus-visible:ring-[var(--color-brand-600)] focus-visible:ring-offset-2"
|
||||||
borderRadius: "var(--radius-xl)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
overflow: "hidden",
|
|
||||||
boxShadow: "var(--shadow-card)",
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "box-shadow 200ms, transform 200ms",
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => {
|
|
||||||
(e.currentTarget as HTMLElement).style.boxShadow = "0 8px 24px rgba(37,99,235,.12)";
|
|
||||||
(e.currentTarget as HTMLElement).style.transform = "translateY(-2px)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={e => {
|
|
||||||
(e.currentTarget as HTMLElement).style.boxShadow = "var(--shadow-card)";
|
|
||||||
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Colour accent bar by status */}
|
{/* Colour accent bar by status */}
|
||||||
<div style={{
|
<div
|
||||||
height: 4,
|
style={{
|
||||||
background: status.dot,
|
height: 4,
|
||||||
borderRadius: "var(--radius-xl) var(--radius-xl) 0 0",
|
background: status.dot,
|
||||||
}} />
|
borderRadius: "var(--radius-xl) var(--radius-xl) 0 0",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div style={{ padding: "1.25rem" }}>
|
<div style={{ padding: "1.25rem" }}>
|
||||||
{/* Manufacturer + model */}
|
{/* Manufacturer + model */}
|
||||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{car.manufacturer} {car.model}
|
{car.manufacturer} {car.model}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: 12, color: "var(--color-text-muted)", marginTop: 2 }}>
|
<p
|
||||||
{car.year}{car.color ? ` · ${car.color}` : ""}
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{car.year}
|
||||||
|
{car.color ? ` · ${car.color}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Status badge */}
|
{/* Status badge */}
|
||||||
<span style={{
|
<span
|
||||||
display: "inline-flex", alignItems: "center", gap: 5,
|
style={{
|
||||||
borderRadius: "var(--radius-full)",
|
display: "inline-flex",
|
||||||
border: `1px solid ${status.border}`,
|
alignItems: "center",
|
||||||
background: status.bg,
|
gap: 5,
|
||||||
padding: "0.2rem 0.625rem",
|
borderRadius: "var(--radius-full)",
|
||||||
fontSize: 11, fontWeight: 700, color: status.color,
|
border: `1px solid ${status.border}`,
|
||||||
whiteSpace: "nowrap", flexShrink: 0,
|
background: status.bg,
|
||||||
}}>
|
padding: "0.2rem 0.625rem",
|
||||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: status.dot }} />
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: status.color,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: status.dot,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{status.label}
|
{status.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plate number */}
|
{/* Plate number */}
|
||||||
<div style={{
|
<div
|
||||||
marginTop: "0.875rem",
|
style={{
|
||||||
background: "var(--color-surface-muted)",
|
marginTop: "0.875rem",
|
||||||
border: "1px solid var(--color-border)",
|
background: "var(--color-surface-muted)",
|
||||||
borderRadius: "var(--radius-md)",
|
border: "1px solid var(--color-border)",
|
||||||
padding: "0.5rem 0.75rem",
|
borderRadius: "var(--radius-md)",
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
padding: "0.5rem 0.75rem",
|
||||||
}}>
|
display: "flex",
|
||||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)", fontWeight: 600 }}>رقم اللوحة</span>
|
alignItems: "center",
|
||||||
<span style={{
|
justifyContent: "space-between",
|
||||||
fontFamily: "var(--font-mono)",
|
}}
|
||||||
fontSize: 14, fontWeight: 700, letterSpacing: "0.1em",
|
>
|
||||||
color: "var(--color-brand-700)",
|
<span
|
||||||
}}>
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
رقم اللوحة
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
color: "var(--color-brand-700)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{car.plateLetters} {car.plateNumber}
|
{car.plateLetters} {car.plateNumber}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key attributes grid */}
|
{/* Key attributes grid */}
|
||||||
<div style={{ marginTop: "0.875rem", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "0.875rem",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ fontSize: 11 }}>
|
<div style={{ fontSize: 11 }}>
|
||||||
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>الفرع</span>
|
<span
|
||||||
<span style={{ color: "var(--color-text-primary)", marginTop: 2, display: "block" }}>
|
style={{
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
fontWeight: 600,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
الفرع
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
marginTop: 2,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{car.branch?.name ?? "—"}
|
{car.branch?.name ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11 }}>
|
<div style={{ fontSize: 11 }}>
|
||||||
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>التأمين</span>
|
<span
|
||||||
<span style={{ color: ins?.color ?? "var(--color-text-muted)", marginTop: 2, display: "block", fontWeight: 600 }}>
|
style={{
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
fontWeight: 600,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
التأمين
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: ins?.color ?? "var(--color-text-muted)",
|
||||||
|
marginTop: 2,
|
||||||
|
display: "block",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{ins?.label ?? "—"}
|
{ins?.label ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11 }}>
|
<div style={{ fontSize: 11 }}>
|
||||||
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>انتهاء الاستمارة</span>
|
<span
|
||||||
<span style={{ color: regWarn ? "#D97706" : "var(--color-text-secondary)", marginTop: 2, display: "block", fontWeight: regWarn ? 600 : 400 }}>
|
style={{
|
||||||
{regWarn && "⚠ "}{fmtDateShort(car.registrationExpiryDate)}
|
color: "var(--color-text-muted)",
|
||||||
|
fontWeight: 600,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
انتهاء الاستمارة
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: regWarn ? "#D97706" : "var(--color-text-secondary)",
|
||||||
|
marginTop: 2,
|
||||||
|
display: "block",
|
||||||
|
fontWeight: regWarn ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{regWarn && "⚠ "}
|
||||||
|
{fmtDateShort(car.registrationExpiryDate)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11 }}>
|
<div style={{ fontSize: 11 }}>
|
||||||
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>الطاقة</span>
|
<span
|
||||||
<span style={{ color: "var(--color-text-secondary)", marginTop: 2, display: "block" }}>
|
style={{
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
fontWeight: 600,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
الطاقة
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
marginTop: 2,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{car.capacity != null ? car.capacity : "—"}
|
{car.capacity != null ? car.capacity : "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,22 +327,38 @@ function CarCard({
|
|||||||
height: 34,
|
height: 34,
|
||||||
borderRadius: "var(--radius-md)",
|
borderRadius: "var(--radius-md)",
|
||||||
border: `1px solid ${maintenanceDisabled ? "var(--color-border)" : "#FDE68A"}`,
|
border: `1px solid ${maintenanceDisabled ? "var(--color-border)" : "#FDE68A"}`,
|
||||||
background: maintenanceDisabled ? "var(--color-surface-muted)" : "#FFFBEB",
|
background: maintenanceDisabled
|
||||||
fontSize: 12, fontWeight: 700,
|
? "var(--color-surface-muted)"
|
||||||
|
: "#FFFBEB",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
color: maintenanceDisabled ? "var(--color-text-hint)" : "#854D0E",
|
color: maintenanceDisabled ? "var(--color-text-hint)" : "#854D0E",
|
||||||
cursor: maintenanceDisabled ? "not-allowed" : "pointer",
|
cursor: maintenanceDisabled ? "not-allowed" : "pointer",
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 6,
|
||||||
fontFamily: "var(--font-sans)",
|
fontFamily: "var(--font-sans)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<i
|
||||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
className="ti ti-tool"
|
||||||
</svg>
|
style={{ fontSize: 13 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
إرسال للصيانة
|
إرسال للصيانة
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Footer CTA hint */}
|
{/* Footer CTA hint */}
|
||||||
<p style={{ marginTop: "0.75rem", fontSize: 11, color: "var(--color-brand-600)", fontWeight: 600, textAlign: "left" }}>
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-brand-600)",
|
||||||
|
fontWeight: 600,
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
اضغط لعرض التفاصيل ←
|
اضغط لعرض التفاصيل ←
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,14 +369,14 @@ function CarCard({
|
|||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CarsPage() {
|
export default function CarsPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
const [detailId, setDetailId] = useState<string | null>(null);
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
const [formTarget, setFormTarget] = useState<Car | null | false>(false); // false = closed
|
const [formTarget, setFormTarget] = useState<Car | null | false>(false); // false = closed
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Car | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Car | null>(null);
|
||||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
const [maintenanceTarget, setMaintenanceTarget] = useState<Car | null>(null);
|
const [maintenanceTarget, setMaintenanceTarget] = useState<Car | null>(null);
|
||||||
|
|
||||||
const { toast, notify } = useToast();
|
const { toast, notify } = useToast();
|
||||||
@@ -229,14 +385,24 @@ export default function CarsPage() {
|
|||||||
useCars(page, search);
|
useCars(page, search);
|
||||||
|
|
||||||
// Need a stable ref to the current edit target for the mutation hook
|
// Need a stable ref to the current edit target for the mutation hook
|
||||||
const getEditTarget = useCallback(() =>
|
const getEditTarget = useCallback(
|
||||||
formTarget instanceof Object && formTarget !== null ? formTarget as Car : null,
|
() =>
|
||||||
[formTarget]);
|
formTarget instanceof Object && formTarget !== null
|
||||||
|
? (formTarget as Car)
|
||||||
|
: null,
|
||||||
|
[formTarget],
|
||||||
|
);
|
||||||
|
|
||||||
const { deleting, handleFormSubmit, handleDeleteConfirm } = useCarMutations({
|
const { deleting, handleFormSubmit, handleDeleteConfirm } = useCarMutations({
|
||||||
onSuccess: (msg) => { notify({ type: "success", message: msg }); loadCars(); },
|
onSuccess: (msg) => {
|
||||||
onError: (msg) => notify({ type: "error", message: msg }),
|
notify({ type: "success", message: msg });
|
||||||
onDeleted: (id) => { removeCar(id); setDeleteTarget(null); },
|
loadCars();
|
||||||
|
},
|
||||||
|
onError: (msg) => notify({ type: "error", message: msg }),
|
||||||
|
onDeleted: (id) => {
|
||||||
|
removeCar(id);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
getEditTarget,
|
getEditTarget,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -249,13 +415,17 @@ export default function CarsPage() {
|
|||||||
// person just clicked. Errors surface inline inside the modal's own
|
// person just clicked. Errors surface inline inside the modal's own
|
||||||
// apiError state (its default behavior), not through the outer toast —
|
// apiError state (its default behavior), not through the outer toast —
|
||||||
// onError is intentionally a no-op here.
|
// onError is intentionally a no-op here.
|
||||||
const { handleFormSubmit: handleMaintenanceSubmit } = useCarMaintenanceMutations({
|
const { handleFormSubmit: handleMaintenanceSubmit } =
|
||||||
carId: maintenanceTarget?.id ?? "",
|
useCarMaintenanceMutations({
|
||||||
onSuccess: (msg) => { notify({ type: "success", message: msg }); loadCars(); },
|
carId: maintenanceTarget?.id ?? "",
|
||||||
onError: () => {},
|
onSuccess: (msg) => {
|
||||||
onDeleted: () => {},
|
notify({ type: "success", message: msg });
|
||||||
getEditTarget: () => null,
|
loadCars();
|
||||||
});
|
},
|
||||||
|
onError: () => {},
|
||||||
|
onDeleted: () => {},
|
||||||
|
getEditTarget: () => null,
|
||||||
|
});
|
||||||
|
|
||||||
// ── Render ──────────────────────────────────────────────────────────────────
|
// ── Render ──────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
@@ -266,18 +436,34 @@ export default function CarsPage() {
|
|||||||
<CarDetailPanel
|
<CarDetailPanel
|
||||||
carId={detailId}
|
carId={detailId}
|
||||||
onClose={() => setDetailId(null)}
|
onClose={() => setDetailId(null)}
|
||||||
onEdit={(car) => { setDetailId(null); setFormTarget(car); }}
|
onEdit={(car) => {
|
||||||
onDelete={(car) => { setDetailId(null); setDeleteTarget(car); }}
|
setDetailId(null);
|
||||||
|
setFormTarget(car);
|
||||||
|
}}
|
||||||
|
onDelete={(car) => {
|
||||||
|
setDetailId(null);
|
||||||
|
setDeleteTarget(car);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{formTarget !== false && (
|
{formTarget !== false && (
|
||||||
<CarFormModal
|
<CarFormModal
|
||||||
|
// CHANGE: added key — same fix as UserFormModal/DriverFormModal,
|
||||||
|
// forces a fresh useForm instance (and fresh defaultValues) when
|
||||||
|
// switching between "new" and different edit targets.
|
||||||
|
key={formTarget === null ? "new" : formTarget.id}
|
||||||
editCar={formTarget}
|
editCar={formTarget}
|
||||||
branches={[]}
|
branches={[]}
|
||||||
onClose={() => setFormTarget(false)}
|
onClose={() => setFormTarget(false)}
|
||||||
onSubmit={(payload: CreateCarPayload | UpdateCarPayload, isNew: boolean) =>
|
onSubmit={(
|
||||||
handleFormSubmit(payload, isNew).then((ok) => { if (ok) setFormTarget(false); return ok; })
|
payload: CreateCarPayload | UpdateCarPayload,
|
||||||
|
isNew: boolean,
|
||||||
|
) =>
|
||||||
|
handleFormSubmit(payload, isNew).then((ok) => {
|
||||||
|
if (ok) setFormTarget(false);
|
||||||
|
return ok;
|
||||||
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -296,9 +482,10 @@ export default function CarsPage() {
|
|||||||
editRecord={null}
|
editRecord={null}
|
||||||
carLabel={`${maintenanceTarget.manufacturer} ${maintenanceTarget.model} — ${maintenanceTarget.plateLetters} ${maintenanceTarget.plateNumber}`}
|
carLabel={`${maintenanceTarget.manufacturer} ${maintenanceTarget.model} — ${maintenanceTarget.plateLetters} ${maintenanceTarget.plateNumber}`}
|
||||||
onClose={() => setMaintenanceTarget(null)}
|
onClose={() => setMaintenanceTarget(null)}
|
||||||
onSubmit={(payload: CreateMaintenancePayload | UpdateMaintenancePayload, isNew: boolean) =>
|
onSubmit={(
|
||||||
handleMaintenanceSubmit(payload, isNew)
|
payload: CreateMaintenancePayload | UpdateMaintenancePayload,
|
||||||
}
|
isNew: boolean,
|
||||||
|
) => handleMaintenanceSubmit(payload, isNew)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -306,32 +493,197 @@ export default function CarsPage() {
|
|||||||
<ArchivedCarsModal onClose={() => setArchiveOpen(false)} />
|
<ArchivedCarsModal onClose={() => setArchiveOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} dir="rtl">
|
<section
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<Header state={{ total }} search={search} setSearch={setSearch} module="" setModule={() => {}} setPage={setPage} title="المركبات" mainTitle="إدارة المركبات" name="مركبة" isAudit={false} onAdd={() => setFormTarget(null)}/>
|
<header
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
padding: "1.5rem 2rem",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.3em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إدارة الأسطول
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "1rem",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
المركبات
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إجمالي{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{total}
|
||||||
|
</strong>{" "}
|
||||||
|
مركبة في الأسطول
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ position: "relative", width: 256 }}>
|
||||||
|
<i
|
||||||
|
className="ti ti-search"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 12,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "var(--color-text-hint)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="بحث بالماركة أو اللوحة..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 40,
|
||||||
|
paddingRight: 36,
|
||||||
|
paddingLeft: 12,
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormTarget(null)}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.125rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "none",
|
||||||
|
background: "var(--color-brand-600)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 7,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="ti ti-plus"
|
||||||
|
style={{ fontSize: 14 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
إضافة مركبة
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{/* Error alert */}
|
{/* Error alert */}
|
||||||
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
|
{error && (
|
||||||
|
<Alert type="error" message={error} onClose={() => setError(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Loading ── */}
|
{/* ── Loading ── */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "5rem 0", color: "var(--color-text-muted)" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "5rem 0",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Spinner size="md" className="text-blue-600" />
|
<Spinner size="md" className="text-blue-600" />
|
||||||
<span style={{ fontSize: 14 }}>جارٍ تحميل المركبات…</span>
|
<span style={{ fontSize: 14 }}>جارٍ تحميل المركبات…</span>
|
||||||
</div>
|
</div>
|
||||||
) : cars.length === 0 ? (
|
) : cars.length === 0 ? (
|
||||||
<div style={{
|
<div
|
||||||
borderRadius: "var(--radius-xl)",
|
style={{
|
||||||
border: "2px dashed var(--color-border)",
|
borderRadius: "var(--radius-xl)",
|
||||||
background: "var(--color-surface)",
|
border: "2px dashed var(--color-border)",
|
||||||
padding: "5rem 2rem",
|
background: "var(--color-surface)",
|
||||||
textAlign: "center",
|
padding: "5rem 2rem",
|
||||||
}}>
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ fontSize: 52, marginBottom: 16 }}>🚗</div>
|
<div style={{ fontSize: 52, marginBottom: 16 }}>🚗</div>
|
||||||
<p style={{ fontSize: 16, fontWeight: 600, color: "var(--color-text-primary)" }}>
|
<p
|
||||||
{search ? `لا توجد مركبات تطابق "${search}"` : "لا توجد مركبات بعد"}
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{search
|
||||||
|
? `لا توجد مركبات تطابق "${search}"`
|
||||||
|
: "لا توجد مركبات بعد"}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)", marginTop: 6 }}>
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
marginTop: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
اضغط على "إضافة مركبة" لإضافة أول مركبة في الأسطول.
|
اضغط على "إضافة مركبة" لإضافة أول مركبة في الأسطول.
|
||||||
</p>
|
</p>
|
||||||
{!search && (
|
{!search && (
|
||||||
@@ -339,11 +691,17 @@ export default function CarsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFormTarget(null)}
|
onClick={() => setFormTarget(null)}
|
||||||
style={{
|
style={{
|
||||||
marginTop: 16, height: 40, padding: "0 1.5rem",
|
marginTop: 16,
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.5rem",
|
||||||
borderRadius: "var(--radius-lg)",
|
borderRadius: "var(--radius-lg)",
|
||||||
border: "none", background: "var(--color-brand-600)",
|
border: "none",
|
||||||
fontSize: 13, fontWeight: 700, color: "#FFF",
|
background: "var(--color-brand-600)",
|
||||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
إضافة مركبة
|
إضافة مركبة
|
||||||
@@ -352,11 +710,21 @@ export default function CarsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ── Card grid ── */
|
/* ── Card grid ── */
|
||||||
<div style={{
|
<div
|
||||||
display: "grid",
|
style={{
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
|
display: "grid",
|
||||||
gap: "1rem",
|
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
|
||||||
}}>
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* PERF NOTE: Cards are rendered inline via .map() below. At the
|
||||||
|
current pagination size (10-12 items) this is not a bottleneck.
|
||||||
|
If page size increases (e.g. "show all" mode, a larger
|
||||||
|
page-size selector, or removal of server-side pagination),
|
||||||
|
extract <CarCard> is already its own component above — wrap
|
||||||
|
it in React.memo at that point, and make sure onClick /
|
||||||
|
onSendToMaintenance passed to it are stable (useCallback)
|
||||||
|
so memoization is actually effective. */}
|
||||||
{cars.map((car) => (
|
{cars.map((car) => (
|
||||||
<CarCard
|
<CarCard
|
||||||
key={car.id}
|
key={car.id}
|
||||||
@@ -370,24 +738,44 @@ export default function CarsPage() {
|
|||||||
|
|
||||||
{/* ── Pagination ── */}
|
{/* ── Pagination ── */}
|
||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<div style={{
|
<div
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
style={{
|
||||||
borderRadius: "var(--radius-xl)",
|
display: "flex",
|
||||||
border: "1px solid var(--color-border)",
|
alignItems: "center",
|
||||||
background: "var(--color-surface)",
|
justifyContent: "space-between",
|
||||||
padding: "0.875rem 1.5rem",
|
borderRadius: "var(--radius-xl)",
|
||||||
boxShadow: "var(--shadow-card)",
|
border: "1px solid var(--color-border)",
|
||||||
}}>
|
background: "var(--color-surface)",
|
||||||
|
padding: "0.875rem 1.5rem",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من{" "}
|
صفحة{" "}
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{page}
|
||||||
|
</strong>{" "}
|
||||||
|
من{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{pages}
|
||||||
|
</strong>
|
||||||
{" · "}
|
{" · "}
|
||||||
<span style={{ color: "var(--color-text-hint)" }}>{total} مركبة</span>
|
<span style={{ color: "var(--color-text-hint)" }}>
|
||||||
|
{total} مركبة
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
{[
|
{[
|
||||||
{ label: "← السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
|
{
|
||||||
{ label: "التالي →", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
|
label: "← السابق",
|
||||||
|
action: () => setPage((p) => Math.max(1, p - 1)),
|
||||||
|
disabled: page === 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "التالي →",
|
||||||
|
action: () => setPage((p) => Math.min(pages, p + 1)),
|
||||||
|
disabled: page === pages,
|
||||||
|
},
|
||||||
].map((btn) => (
|
].map((btn) => (
|
||||||
<button
|
<button
|
||||||
key={btn.label}
|
key={btn.label}
|
||||||
@@ -398,7 +786,8 @@ export default function CarsPage() {
|
|||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
background: "var(--color-surface-muted)",
|
background: "var(--color-surface-muted)",
|
||||||
padding: "0.375rem 0.875rem",
|
padding: "0.375rem 0.875rem",
|
||||||
fontSize: 12, color: "var(--color-text-secondary)",
|
fontSize: 12,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
cursor: btn.disabled ? "not-allowed" : "pointer",
|
cursor: btn.disabled ? "not-allowed" : "pointer",
|
||||||
opacity: btn.disabled ? 0.4 : 1,
|
opacity: btn.disabled ? 0.4 : 1,
|
||||||
fontFamily: "var(--font-sans)",
|
fontFamily: "var(--font-sans)",
|
||||||
@@ -417,4 +806,4 @@ export default function CarsPage() {
|
|||||||
<ArchiveButton onClick={() => setArchiveOpen(true)} />
|
<ArchiveButton onClick={() => setArchiveOpen(true)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +1,339 @@
|
|||||||
// app/dashboard/[clientId]/addresses/[addressesId]/page.tsx
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { Alert, Spinner, Button } from "@/src/Components/UI";
|
import { Alert, ConfirmDialog, Toast } from "@/src/Components/UI";
|
||||||
import { AddressDetails } from "@/src/Components/Client_Adress/AddressDetails";
|
import { AddressFormModal } from "@/src/Components/Client";
|
||||||
import { clientAddressService } from "@/src/services/clientAddress.service";
|
import { AddressDetails } from "@/src/Components/Client_Adress/AddressDetails";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { clientAddressService } from "@/src/services/clientAddress.service";
|
||||||
import type { ClientAddress } from "@/src/types/client_adresses";
|
import { clientService } from "@/src/services/client.service";
|
||||||
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
|
|
||||||
export default function AddressDetailsPage() {
|
import type { Client } from "@/src/types/client";
|
||||||
const params = useParams();
|
import type { ClientAddress } from "@/src/types/client_adresses";
|
||||||
const router = useRouter();
|
import type { ToastNotification } from "@/src/Components/UI";
|
||||||
|
import type {
|
||||||
|
CreateAddressFormValues,
|
||||||
|
UpdateAddressFormValues,
|
||||||
|
} from "@/src/validations/client_address.validator";
|
||||||
|
|
||||||
const clientId = params?.clientId as string | undefined;
|
// ─── Page ──────────────────────────────────────────────────────────────────
|
||||||
const addressId = params?.addressesId as string | undefined;
|
|
||||||
|
|
||||||
|
export default function AddressDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const clientId = (params?.clientId ?? params?.id) as string | undefined;
|
||||||
|
const addressId = params?.addressId as string | undefined;
|
||||||
|
|
||||||
|
// ── Data state ───────────────────────────────────────────────────────────
|
||||||
const [address, setAddress] = useState<ClientAddress | null>(null);
|
const [address, setAddress] = useState<ClientAddress | null>(null);
|
||||||
|
const [client, setClient] = useState<Client | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadAddress = useCallback(async () => {
|
// ── Modal state ──────────────────────────────────────────────────────────
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// ── Toast ────────────────────────────────────────────────────────────────
|
||||||
|
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
||||||
|
|
||||||
|
const notify = (n: ToastNotification) => {
|
||||||
|
setNotification(n);
|
||||||
|
setTimeout(() => setNotification(null), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Fetch ────────────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
if (!clientId || !addressId) return;
|
if (!clientId || !addressId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
|
||||||
try {
|
Promise.all([
|
||||||
const token = getStoredToken();
|
clientAddressService.getById(clientId, addressId, getStoredToken()),
|
||||||
const res = await clientAddressService.getById(clientId, addressId, token);
|
clientService.getById(clientId, getStoredToken()),
|
||||||
// normalize in case the API returns _id instead of id
|
])
|
||||||
const raw = res.data as ClientAddress & { _id?: string };
|
.then(([addrRes, clientRes]) => {
|
||||||
setAddress({ ...raw, id: raw.id ?? raw._id ?? "" });
|
const raw = (addrRes as any).data ?? addrRes;
|
||||||
} catch {
|
setAddress({ ...raw, id: raw.id ?? raw._id });
|
||||||
setError("تعذّر تحميل بيانات العنوان. يرجى المحاولة لاحقاً.");
|
setClient(clientRes?.data);
|
||||||
} finally {
|
})
|
||||||
setLoading(false);
|
.catch(() => setError("تعذّر تحميل بيانات العنوان. يرجى المحاولة مجدداً."))
|
||||||
}
|
.finally(() => setLoading(false));
|
||||||
}, [clientId, addressId]);
|
}, [clientId, addressId]);
|
||||||
|
|
||||||
useEffect(() => {
|
// ── Update ───────────────────────────────────────────────────────────────
|
||||||
loadAddress();
|
const handleUpdateSubmit = async (
|
||||||
}, [loadAddress]);
|
data: CreateAddressFormValues | UpdateAddressFormValues
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!clientId || !addressId) return false;
|
||||||
|
try {
|
||||||
|
const res = await clientAddressService.update(
|
||||||
|
clientId,
|
||||||
|
addressId,
|
||||||
|
data as UpdateAddressFormValues,
|
||||||
|
getStoredToken()
|
||||||
|
);
|
||||||
|
const raw = (res as any).data ?? res;
|
||||||
|
setAddress({ ...raw, id: raw.id ?? raw._id });
|
||||||
|
notify({ type: "success", message: "تم تحديث العنوان بنجاح." });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message: err instanceof Error ? err.message : "تعذّر تحديث العنوان.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ── Loading ──────────────────────────────────────────────────────────────
|
// ── Delete ───────────────────────────────────────────────────────────────
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!clientId || !addressId || deleting) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await clientAddressService.delete(clientId, addressId, getStoredToken());
|
||||||
|
notify({ type: "success", message: "تم حذف العنوان بنجاح." });
|
||||||
|
// short delay so the toast shows before navigation
|
||||||
|
setTimeout(() => {
|
||||||
|
router.replace(`/dashboard/clients/${clientId}/addresses`);
|
||||||
|
}, 700);
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message: err instanceof Error ? err.message : "تعذّر حذف العنوان.",
|
||||||
|
});
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Guards ───────────────────────────────────────────────────────────────
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "60vh", gap: 12 }}>
|
<div
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
|
||||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>جارٍ تحميل بيانات العنوان…</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Error ────────────────────────────────────────────────────────────────
|
|
||||||
if (error || !address) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem", maxWidth: 640, margin: "2rem auto" }}>
|
|
||||||
<Alert type="error" message={error ?? "العنوان غير موجود."} />
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => router.push(`/dashboard/${clientId}/addresses`)}
|
|
||||||
>
|
|
||||||
العودة إلى العناوين
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Content ──────────────────────────────────────────────────────────────
|
|
||||||
return (
|
|
||||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.25rem", maxWidth: 720, margin: "0 auto", padding: "2rem 1rem" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push(`/dashboard/${clientId}/addresses`)}
|
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 6,
|
justifyContent: "center",
|
||||||
fontSize: 12,
|
minHeight: "60vh",
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<div
|
||||||
<path d="M15 18l-6-6 6-6" />
|
style={{
|
||||||
</svg>
|
width: 36,
|
||||||
كل العناوين
|
height: 36,
|
||||||
</button>
|
borderRadius: "50%",
|
||||||
|
border: "3px solid var(--color-border)",
|
||||||
|
borderTopColor: "var(--color-brand-600)",
|
||||||
|
animation: "spin 0.7s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<AddressDetails address={address} />
|
if (error || !address) {
|
||||||
</section>
|
return (
|
||||||
|
<section style={{ padding: "2rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
|
<Alert type="error" message={error ?? "لم يتم العثور على العنوان."} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-brand-600)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← رجوع
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toast notification={notification} />
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
{editOpen && (
|
||||||
|
<AddressFormModal
|
||||||
|
editAddress={address}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
onSubmit={handleUpdateSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
loading={deleting}
|
||||||
|
onCancel={() => { if (!deleting) setDeleteOpen(false); }}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
title="حذف العميل"
|
||||||
|
description={`هل أنت متأكد من حذف ${address.label}؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
|
|
||||||
|
{/* ── Page header ── */}
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
padding: "1.5rem 2rem",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Back link */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/dashboard/clients/${clientId}/addresses`)}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
marginBottom: 12,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
{client ? `${client.name} — العناوين` : "العناوين"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.3em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
تفاصيل العنوان
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{address.label}
|
||||||
|
</h1>
|
||||||
|
{client && (
|
||||||
|
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||||
|
{client.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid #BFDBFE",
|
||||||
|
background: "#EFF6FF",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#1D4ED8",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 7,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
تعديل العنوان
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
background: "#FEF2F2",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#DC2626",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 7,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14H6L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4h6v2" />
|
||||||
|
</svg>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Address details component ── */}
|
||||||
|
<AddressDetails address={address} />
|
||||||
|
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,16 +8,21 @@ import {
|
|||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
Toast,
|
Toast,
|
||||||
ArchiveButton,
|
ArchiveButton,
|
||||||
|
Button,
|
||||||
|
EmptyState,
|
||||||
|
PageLoader,
|
||||||
|
InlineLoader,
|
||||||
} from "@/src/Components/UI";
|
} from "@/src/Components/UI";
|
||||||
|
|
||||||
import { useClientAddresses } from "@/src/hooks/useClientAddresses";
|
import { useClientAddresses } from "@/src/hooks/useClientAddresses";
|
||||||
import { clientService } from "@/src/services/client.service";
|
import { clientService } from "@/src/services/client.service";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
|
|
||||||
import type { Client, ClientFormData } from "@/src/types/client";
|
import type { Client, ClientFormData } from "@/src/types/client";
|
||||||
import type { ClientAddress } from "@/src/types/client_adresses";
|
import type { ClientAddress } from "@/src/types/client_adresses";
|
||||||
|
|
||||||
import { AddressFormModal, ClientFormModal } from "@/src/Components/Client";
|
import { AddressFormModal, ClientFormModal } from "@/src/Components/Client";
|
||||||
|
import { AddressDetailModal } from "@/src/Components/Client_Adress/AddressDetailModal";
|
||||||
import { ArchivedClientsAddressesModal } from "@/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal";
|
import { ArchivedClientsAddressesModal } from "@/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal";
|
||||||
import type {
|
import type {
|
||||||
CreateAddressFormValues,
|
CreateAddressFormValues,
|
||||||
@@ -28,38 +33,36 @@ import type {
|
|||||||
|
|
||||||
function labelIcon(label: string): string {
|
function labelIcon(label: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
فوترة: "💳",
|
"فوترة": "💳",
|
||||||
شحن: "📦",
|
"شحن": "📦",
|
||||||
"المقر الرئيسي": "🏢",
|
"المقر الرئيسي": "🏢",
|
||||||
فرع: "🏬",
|
"فرع": "🏬",
|
||||||
مستودع: "🏭",
|
"مستودع": "🏭",
|
||||||
billing: "💳",
|
billing: "💳",
|
||||||
shipping: "📦",
|
shipping: "📦",
|
||||||
"head office": "🏢",
|
"head office": "🏢",
|
||||||
branch: "🏬",
|
branch: "🏬",
|
||||||
warehouse: "🏭",
|
warehouse: "🏭",
|
||||||
};
|
};
|
||||||
return map[label.toLowerCase()] ?? "📍";
|
return map[label.toLowerCase()] ?? "📍";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── AddressCard ───────────────────────────────────────────────────────────
|
// ─── AddressCard ───────────────────────────────────────────────────────────
|
||||||
|
// CHANGE: clicking the card now opens AddressDetailModal — same pattern as
|
||||||
|
// UserTable → UserDetailModal (viewUserId). Action buttons stop propagation
|
||||||
|
// so they don't trigger the modal. `ActionBtn` (custom component) removed in
|
||||||
|
// favor of the shared <Button size="sm" /> from the template.
|
||||||
|
|
||||||
interface AddressCardProps {
|
interface AddressCardProps {
|
||||||
address: ClientAddress;
|
address: ClientAddress;
|
||||||
onView: () => void;
|
onView: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onSetPrimary: () => void;
|
onSetPrimary: () => void;
|
||||||
settingPrimary: boolean; // true only while THIS card's request is in flight
|
settingPrimary: boolean; // true only while THIS card's request is in flight
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddressCard({
|
function AddressCard({ address, onView, onEdit, onDelete, onSetPrimary, settingPrimary }: AddressCardProps) {
|
||||||
address,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onSetPrimary,
|
|
||||||
settingPrimary,
|
|
||||||
}: AddressCardProps) {
|
|
||||||
const { details, contactPerson } = address;
|
const { details, contactPerson } = address;
|
||||||
const {
|
const {
|
||||||
street,
|
street,
|
||||||
@@ -75,6 +78,10 @@ function AddressCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
onClick={onView}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onView(); }}
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -85,7 +92,7 @@ function AddressCard({
|
|||||||
padding: "1.25rem",
|
padding: "1.25rem",
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
boxShadow: "var(--shadow-card)",
|
boxShadow: "var(--shadow-card)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Label row */}
|
{/* Label row */}
|
||||||
@@ -149,13 +156,7 @@ function AddressCard({
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p
|
<p style={{ margin: 0, fontWeight: 600, color: "var(--color-text-primary)" }}>
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{street}
|
{street}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -168,14 +169,7 @@ function AddressCard({
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{(buildingNo || unitNo || additionalNo) && (
|
{(buildingNo || unitNo || additionalNo) && (
|
||||||
<p
|
<p style={{ margin: 0, fontSize: 12, color: "var(--color-text-muted)" }} dir="ltr">
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
dir="ltr"
|
|
||||||
>
|
|
||||||
{buildingNo && `مبنى ${buildingNo}`}
|
{buildingNo && `مبنى ${buildingNo}`}
|
||||||
{unitNo && ` · وحدة ${unitNo}`}
|
{unitNo && ` · وحدة ${unitNo}`}
|
||||||
{additionalNo && ` · رقم إضافي ${additionalNo}`}
|
{additionalNo && ` · رقم إضافي ${additionalNo}`}
|
||||||
@@ -209,9 +203,9 @@ function AddressCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions — stopPropagation so clicking them doesn't open the detail modal */}
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
@@ -221,131 +215,31 @@ function AddressCard({
|
|||||||
borderTop: "1px solid var(--color-border)",
|
borderTop: "1px solid var(--color-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ActionBtn
|
<Button type="button" variant="secondary" size="sm" onClick={onEdit}>
|
||||||
onClick={onEdit}
|
|
||||||
color="#1D4ED8"
|
|
||||||
bg="#EFF6FF"
|
|
||||||
border="#BFDBFE"
|
|
||||||
>
|
|
||||||
تعديل
|
تعديل
|
||||||
</ActionBtn>
|
</Button>
|
||||||
|
|
||||||
{!address.isPrimary && (
|
{!address.isPrimary && (
|
||||||
<ActionBtn
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={onSetPrimary}
|
onClick={onSetPrimary}
|
||||||
disabled={settingPrimary}
|
loading={settingPrimary}
|
||||||
color="#B45309"
|
|
||||||
bg="#FFFBEB"
|
|
||||||
border="#FDE68A"
|
|
||||||
>
|
>
|
||||||
{settingPrimary ? "جارٍ التعيين…" : "تعيين كأساسي"}
|
تعيين كأساسي
|
||||||
</ActionBtn>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ActionBtn
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
color="#DC2626"
|
style={{ marginInlineStart: "auto" }}
|
||||||
bg="#FEF2F2"
|
|
||||||
border="#FECACA"
|
|
||||||
style={{ marginRight: "auto" }}
|
|
||||||
>
|
>
|
||||||
حذف
|
حذف
|
||||||
</ActionBtn>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionBtn({
|
|
||||||
onClick,
|
|
||||||
color,
|
|
||||||
bg,
|
|
||||||
border,
|
|
||||||
style,
|
|
||||||
disabled,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
onClick: () => void;
|
|
||||||
color: string;
|
|
||||||
bg: string;
|
|
||||||
border: string;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
disabled?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
style={{
|
|
||||||
height: 30,
|
|
||||||
padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: `1px solid ${border}`,
|
|
||||||
background: bg,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color,
|
|
||||||
cursor: disabled ? "not-allowed" : "pointer",
|
|
||||||
opacity: disabled ? 0.6 : 1,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
transition: "opacity 150ms",
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ClientEditModal ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface ClientEditModalProps {
|
|
||||||
client: Client;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (data: ClientFormData, isNew: boolean) => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClientEditModal({ client, onClose, onSubmit }: ClientEditModalProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="تعديل بيانات العميل"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) onClose();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 50,
|
|
||||||
background: "rgba(15,23,42,0.55)",
|
|
||||||
backdropFilter: "blur(4px)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "2rem 1rem",
|
|
||||||
overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 520,
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
borderRadius: "var(--radius-2xl)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ClientFormModal
|
|
||||||
editClient={client}
|
|
||||||
onClose={onClose}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -354,8 +248,8 @@ function ClientEditModal({ client, onClose, onSubmit }: ClientEditModalProps) {
|
|||||||
// ─── Page ──────────────────────────────────────────────────────────────────
|
// ─── Page ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ClientAddressesPage() {
|
export default function ClientAddressesPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const clientId = (params?.clientId ?? params?.id) as string | undefined;
|
const clientId = (params?.clientId ?? params?.id) as string | undefined;
|
||||||
|
|
||||||
@@ -366,7 +260,7 @@ export default function ClientAddressesPage() {
|
|||||||
}, [clientId]);
|
}, [clientId]);
|
||||||
|
|
||||||
// ── Parent client ────────────────────────────────────────────────────────
|
// ── Parent client ────────────────────────────────────────────────────────
|
||||||
const [client, setClient] = useState<Client | null>(null);
|
const [client, setClient] = useState<Client | null>(null);
|
||||||
const [clientLoading, setClientLoading] = useState(true);
|
const [clientLoading, setClientLoading] = useState(true);
|
||||||
|
|
||||||
const loadClient = useCallback(() => {
|
const loadClient = useCallback(() => {
|
||||||
@@ -400,21 +294,20 @@ export default function ClientAddressesPage() {
|
|||||||
} = useClientAddresses(clientId ?? "");
|
} = useClientAddresses(clientId ?? "");
|
||||||
|
|
||||||
// ── Modal state ──────────────────────────────────────────────────────────
|
// ── Modal state ──────────────────────────────────────────────────────────
|
||||||
const [addrFormTarget, setAddrFormTarget] = useState<
|
const [addrFormTarget, setAddrFormTarget] = useState<ClientAddress | null | false>(false);
|
||||||
ClientAddress | null | false
|
const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null);
|
||||||
>(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null);
|
const [editingClient, setEditingClient] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
const [editingClient, setEditingClient] = useState(false);
|
|
||||||
// Archive browser modal open/closed — scoped to this client's addresses
|
// Archive browser modal open/closed — scoped to this client's addresses
|
||||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
|
// Address whose detail modal is open — same pattern as viewUserId/UserDetailModal
|
||||||
|
const [viewAddress, setViewAddress] = useState<ClientAddress | null>(null);
|
||||||
|
|
||||||
// ── Address form handler ─────────────────────────────────────────────────
|
// ── Address form handler ─────────────────────────────────────────────────
|
||||||
const handleAddressSubmit = async (
|
const handleAddressSubmit = async (
|
||||||
data: CreateAddressFormValues | UpdateAddressFormValues,
|
data: CreateAddressFormValues | UpdateAddressFormValues
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (addrFormTarget === null)
|
if (addrFormTarget === null) return createAddress(data as CreateAddressFormValues);
|
||||||
return createAddress(data as CreateAddressFormValues);
|
|
||||||
if (addrFormTarget === false) return false;
|
if (addrFormTarget === false) return false;
|
||||||
return updateAddress(addrFormTarget.id, data as UpdateAddressFormValues);
|
return updateAddress(addrFormTarget.id, data as UpdateAddressFormValues);
|
||||||
};
|
};
|
||||||
@@ -448,28 +341,9 @@ export default function ClientAddressesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Loading guard ────────────────────────────────────────────────────────
|
// ── Loading guard ────────────────────────────────────────────────────────
|
||||||
|
// CHANGE: swapped the hand-rolled spinning <div> for the template's PageLoader.
|
||||||
if (!clientId || clientLoading) {
|
if (!clientId || clientLoading) {
|
||||||
return (
|
return <PageLoader message="جارٍ تحميل بيانات العميل…" />;
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
minHeight: "100vh",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: "50%",
|
|
||||||
border: "3px solid var(--color-border)",
|
|
||||||
borderTopColor: "var(--color-brand-600)",
|
|
||||||
animation: "spin 0.7s linear infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
@@ -477,6 +351,14 @@ export default function ClientAddressesPage() {
|
|||||||
<>
|
<>
|
||||||
<Toast notification={notification} />
|
<Toast notification={notification} />
|
||||||
|
|
||||||
|
{/* Address detail modal — same pattern as viewUserId → UserDetailModal */}
|
||||||
|
{viewAddress && (
|
||||||
|
<AddressDetailModal
|
||||||
|
address={viewAddress}
|
||||||
|
onClose={() => setViewAddress(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Address create / edit modal */}
|
{/* Address create / edit modal */}
|
||||||
{addrFormTarget !== false && (
|
{addrFormTarget !== false && (
|
||||||
<AddressFormModal
|
<AddressFormModal
|
||||||
@@ -490,18 +372,22 @@ export default function ClientAddressesPage() {
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
onCancel={() => {
|
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||||
if (!deleting) setDeleteTarget(null);
|
|
||||||
}}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
title="حذف العميل"
|
title="حذف العنوان"
|
||||||
description={`هل أنت متأكد من حذف ${deleteTarget?.label ?? ""}؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.`}
|
description={`هل أنت متأكد من حذف ${deleteTarget?.label ?? ""}؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Client edit modal */}
|
{/*
|
||||||
|
Client edit modal
|
||||||
|
CHANGE: removed the custom `ClientEditModal` wrapper that built its
|
||||||
|
own fixed-position backdrop by hand. `ClientFormModal` already
|
||||||
|
renders the shared <Modal/> internally, so wrapping it in another
|
||||||
|
backdrop produced two stacked overlays. We now render it directly.
|
||||||
|
*/}
|
||||||
{editingClient && client && (
|
{editingClient && client && (
|
||||||
<ClientEditModal
|
<ClientFormModal
|
||||||
client={client}
|
editClient={client}
|
||||||
onClose={() => setEditingClient(false)}
|
onClose={() => setEditingClient(false)}
|
||||||
onSubmit={handleClientEditSubmit}
|
onSubmit={handleClientEditSubmit}
|
||||||
/>
|
/>
|
||||||
@@ -515,9 +401,8 @@ export default function ClientAddressesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
|
|
||||||
>
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
@@ -528,145 +413,48 @@ export default function ClientAddressesPage() {
|
|||||||
boxShadow: "var(--shadow-card)",
|
boxShadow: "var(--shadow-card)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
{/* CHANGE: custom <button> → Button variant="ghost" */}
|
||||||
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() => router.push("/dashboard/clients")}
|
onClick={() => router.push("/dashboard/clients")}
|
||||||
style={{
|
style={{ marginBottom: 12 }}
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
marginBottom: 12,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<svg
|
<i className="ti ti-chevron-left" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M15 18l-6-6 6-6" />
|
|
||||||
</svg>
|
|
||||||
جميع العملاء
|
جميع العملاء
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<p
|
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
letterSpacing: "0.3em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: "#2563EB",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
إدارة العناوين
|
إدارة العناوين
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1
|
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
style={{
|
|
||||||
fontSize: "1.5rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{client ? `${client.name} — العناوين` : "العناوين"}
|
{client ? `${client.name} — العناوين` : "العناوين"}
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||||
style={{
|
|
||||||
marginTop: "0.25rem",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
إجمالي{" "}
|
إجمالي{" "}
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
<strong style={{ color: "var(--color-text-primary)" }}>{addresses.length}</strong>{" "}
|
||||||
{addresses.length}
|
|
||||||
</strong>{" "}
|
|
||||||
عنوان
|
عنوان
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
|
{/* CHANGE: custom <button> → Button variant="secondary" */}
|
||||||
{client && (
|
{client && (
|
||||||
<button
|
<Button type="button" variant="secondary" onClick={() => setEditingClient(true)}>
|
||||||
type="button"
|
<i className="ti ti-edit" style={{ fontSize: 13 }} aria-hidden="true" />
|
||||||
onClick={() => setEditingClient(true)}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: "0 1rem",
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 7,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="13"
|
|
||||||
height="13"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
تعديل بيانات العميل
|
تعديل بيانات العميل
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{/* CHANGE: custom <button> → Button variant="primary" */}
|
||||||
type="button"
|
<Button type="button" variant="primary" onClick={() => setAddrFormTarget(null)}>
|
||||||
onClick={() => setAddrFormTarget(null)}
|
<i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: "0 1.125rem",
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "none",
|
|
||||||
background: "var(--color-brand-600)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#FFF",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 7,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
>
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
إضافة عنوان
|
إضافة عنوان
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -685,15 +473,8 @@ export default function ClientAddressesPage() {
|
|||||||
borderTop: "1px solid var(--color-border)",
|
borderTop: "1px solid var(--color-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>{client.name}</span>
|
||||||
style={{ fontWeight: 600, color: "var(--color-text-primary)" }}
|
<a href={`mailto:${client.email}`} style={{ color: "#2563EB", textDecoration: "none" }}>
|
||||||
>
|
|
||||||
{client.name}
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href={`mailto:${client.email}`}
|
|
||||||
style={{ color: "#2563EB", textDecoration: "none" }}
|
|
||||||
>
|
|
||||||
{client.email}
|
{client.email}
|
||||||
</a>
|
</a>
|
||||||
<span>{client.phone}</span>
|
<span>{client.phone}</span>
|
||||||
@@ -718,79 +499,20 @@ export default function ClientAddressesPage() {
|
|||||||
|
|
||||||
{/* ── Address grid ── */}
|
{/* ── Address grid ── */}
|
||||||
{addrLoading ? (
|
{addrLoading ? (
|
||||||
<div
|
// CHANGE: hand-rolled spinning <div> → InlineLoader
|
||||||
style={{
|
<InlineLoader message="جارٍ تحميل العناوين…" />
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "4rem 0",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
gap: 12,
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
borderRadius: "50%",
|
|
||||||
border: "2px solid var(--color-border)",
|
|
||||||
borderTopColor: "var(--color-brand-600)",
|
|
||||||
animation: "spin 0.7s linear infinite",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
جارٍ تحميل العناوين…
|
|
||||||
</div>
|
|
||||||
) : addresses.length === 0 ? (
|
) : addresses.length === 0 ? (
|
||||||
<div
|
// CHANGE: custom empty-state <div> → EmptyState
|
||||||
style={{
|
<EmptyState
|
||||||
borderRadius: "var(--radius-xl)",
|
icon="📍"
|
||||||
border: "1px solid var(--color-border)",
|
title="لا توجد عناوين بعد"
|
||||||
background: "var(--color-surface)",
|
description="أضف عنواناً واحداً على الأقل حتى يتمكن العميل من استقبال الشحنات والفواتير."
|
||||||
padding: "4rem 1rem",
|
action={
|
||||||
textAlign: "center",
|
<Button type="button" variant="primary" onClick={() => setAddrFormTarget(null)}>
|
||||||
boxShadow: "var(--shadow-card)",
|
أضف أول عنوان
|
||||||
}}
|
</Button>
|
||||||
>
|
}
|
||||||
<p style={{ fontSize: 32, margin: 0 }}>📍</p>
|
/>
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
marginTop: 12,
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
لا توجد عناوين بعد
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
marginTop: 4,
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
أضف عنواناً واحداً على الأقل حتى يتمكن العميل من استقبال الشحنات
|
|
||||||
والفواتير.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setAddrFormTarget(null)}
|
|
||||||
style={{
|
|
||||||
marginTop: 16,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-brand-600)",
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
textDecoration: "underline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
أضف أول عنوان
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -799,26 +521,23 @@ export default function ClientAddressesPage() {
|
|||||||
gap: "1rem",
|
gap: "1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{addresses.map((addr) => (
|
{addresses.map((addr) => (
|
||||||
<AddressCard
|
<AddressCard
|
||||||
key={addr.id}
|
key={addr.id}
|
||||||
address={addr}
|
address={addr}
|
||||||
onView={() => router.push(`/dashboard/${clientId}/addresses/${addr.id}`)}
|
onView={() => setViewAddress(addr)}
|
||||||
onEdit={() => setAddrFormTarget(addr)}
|
onEdit={() => setAddrFormTarget(addr)}
|
||||||
onDelete={() => setDeleteTarget(addr)}
|
onDelete={() => setDeleteTarget(addr)}
|
||||||
onSetPrimary={() => handleSetPrimary(addr)}
|
onSetPrimary={() => handleSetPrimary(addr)}
|
||||||
settingPrimary={settingPrimaryId === addr.id}
|
settingPrimary={settingPrimaryId === addr.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Floating button to open the address archive for this client */}
|
{/* Floating button to open the address archive for this client */}
|
||||||
<ArchiveButton
|
<ArchiveButton onClick={() => setArchiveOpen(true)} label="أرشيف العناوين" />
|
||||||
onClick={() => setArchiveOpen(true)}
|
|
||||||
label="أرشيف العناوين"
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,41 +2,70 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { ConfirmDialog, Spinner } from "@/src/Components/UI";
|
import { ConfirmDialog, Spinner, Button, Toast } from "@/src/Components/UI";
|
||||||
import { DriverFormModal , PhotoCard } from "@/src/Components/Driver";
|
import { DriverFormModal, PhotoCard } from "@/src/Components/Driver";
|
||||||
import { DriverReportPanel } from "@/src/Components/Driver_Report/driverReport";
|
import { DriverReportPanel } from "@/src/Components/Driver_Report/driverReport";
|
||||||
import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver";
|
import type {
|
||||||
import { DRIVER_STATUS_MAP, DRIVER_CARD_TYPE_MAP, NATIONAL_ID_TYPE_MAP } from "@/src/types/driver";
|
Driver,
|
||||||
|
CreateDriverPayload,
|
||||||
|
UpdateDriverPayload,
|
||||||
|
} from "@/src/types/driver";
|
||||||
|
import {
|
||||||
|
DRIVER_STATUS_MAP,
|
||||||
|
DRIVER_CARD_TYPE_MAP,
|
||||||
|
NATIONAL_ID_TYPE_MAP,
|
||||||
|
} from "@/src/types/driver";
|
||||||
import { driverService } from "@/src/services";
|
import { driverService } from "@/src/services";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
|
|
||||||
function fmtDate(iso?: string | null): string {
|
function fmtDate(iso?: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||||
year: "numeric", month: "long", day: "numeric",
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExpiringSoon(iso?: string | null): boolean {
|
function isExpiringSoon(iso?: string | null): boolean {
|
||||||
if (!iso) return false;
|
if (!iso) return false;
|
||||||
return (new Date(iso).getTime() - Date.now()) <= 90 * 86_400_000;
|
return new Date(iso).getTime() - Date.now() <= 90 * 86_400_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
|
function SectionCard({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
borderRadius: "var(--radius-xl)",
|
style={{
|
||||||
border: "1px solid var(--color-border)",
|
borderRadius: "var(--radius-xl)",
|
||||||
background: "var(--color-surface)",
|
border: "1px solid var(--color-border)",
|
||||||
overflow: "hidden",
|
background: "var(--color-surface)",
|
||||||
boxShadow: "var(--shadow-card)",
|
overflow: "hidden",
|
||||||
}}>
|
boxShadow: "var(--shadow-card)",
|
||||||
<div style={{
|
}}
|
||||||
padding: "0.875rem 1.5rem",
|
>
|
||||||
borderBottom: "1px solid var(--color-border)",
|
<div
|
||||||
background: "var(--color-surface-muted)",
|
style={{
|
||||||
}}>
|
padding: "0.875rem 1.5rem",
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: 0 }}>
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.25em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-text-hint)",
|
||||||
|
fontWeight: 700,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,50 +74,82 @@ function SectionCard({ title, children }: { title: string; children: React.React
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailRow({ label, value, mono = false, warn = false }: {
|
function DetailRow({
|
||||||
label: string; value: string; mono?: boolean; warn?: boolean;
|
label,
|
||||||
|
value,
|
||||||
|
mono = false,
|
||||||
|
warn = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
warn?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
style={{
|
||||||
padding: "0.55rem 0", borderBottom: "1px solid var(--color-border)",
|
display: "flex",
|
||||||
}}>
|
justifyContent: "space-between",
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>{label}</span>
|
alignItems: "baseline",
|
||||||
<span style={{
|
padding: "0.55rem 0",
|
||||||
fontSize: 13,
|
borderBottom: "1px solid var(--color-border)",
|
||||||
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
}}
|
||||||
color: warn ? "#D97706" : "var(--color-text-primary)",
|
>
|
||||||
fontWeight: warn ? 600 : 400,
|
<span
|
||||||
maxWidth: "60%", textAlign: "left", wordBreak: "break-word",
|
style={{
|
||||||
}}>
|
fontSize: 12,
|
||||||
{warn && value !== "—" ? "⚠ " : ""}{value}
|
color: "var(--color-text-muted)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||||
|
color: warn ? "#D97706" : "var(--color-text-primary)",
|
||||||
|
fontWeight: warn ? 600 : 400,
|
||||||
|
maxWidth: "60%",
|
||||||
|
textAlign: "left",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{warn && value !== "—" ? "⚠ " : ""}
|
||||||
|
{value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DriverDetailPage() {
|
export default function DriverDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const driverId = params?.driverId as string;
|
const driverId = params?.driverId as string;
|
||||||
|
|
||||||
const [driver, setDriver] = useState<Driver | null>(null);
|
const [driver, setDriver] = useState<Driver | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [avatarError, setAvatarError] = useState(false);
|
const [avatarError, setAvatarError] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => setAvatarError(false));
|
queueMicrotask(() => setAvatarError(false));
|
||||||
}, [driver?.photoUrl]);
|
}, [driver?.photoUrl]);
|
||||||
const [toast, setToast] = useState<{ type: "success" | "error"; message: string } | null>(null);
|
const [toast, setToast] = useState<{
|
||||||
|
type: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const showToast = useCallback((type: "success" | "error", message: string) => {
|
const showToast = useCallback(
|
||||||
setToast({ type, message });
|
(type: "success" | "error", message: string) => {
|
||||||
setTimeout(() => setToast(null), 4000);
|
setToast({ type, message });
|
||||||
}, []);
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const loadDriver = useCallback(async () => {
|
const loadDriver = useCallback(async () => {
|
||||||
if (!driverId) return;
|
if (!driverId) return;
|
||||||
@@ -99,23 +160,34 @@ export default function DriverDetailPage() {
|
|||||||
const data = await driverService.getById(driverId, token);
|
const data = await driverService.getById(driverId, token);
|
||||||
setDriver(data);
|
setDriver(data);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات السائق.");
|
setError(
|
||||||
|
err instanceof Error ? err.message : "تعذّر تحميل بيانات السائق.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [driverId]);
|
}, [driverId]);
|
||||||
|
|
||||||
useEffect(() => { queueMicrotask(loadDriver); }, [loadDriver]);
|
useEffect(() => {
|
||||||
|
queueMicrotask(loadDriver);
|
||||||
|
}, [loadDriver]);
|
||||||
|
|
||||||
const handleEditSubmit = useCallback(
|
const handleEditSubmit = useCallback(
|
||||||
async (payload: CreateDriverPayload | UpdateDriverPayload): Promise<boolean> => {
|
async (
|
||||||
|
payload: CreateDriverPayload | UpdateDriverPayload,
|
||||||
|
): Promise<boolean> => {
|
||||||
if (!driver) return false;
|
if (!driver) return false;
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const typedPayload = payload as UpdateDriverPayload & {
|
const typedPayload = payload as UpdateDriverPayload & {
|
||||||
photo?: File; nationalPhoto?: File; driverCardPhoto?: File;
|
photo?: File;
|
||||||
|
nationalPhoto?: File;
|
||||||
|
driverCardPhoto?: File;
|
||||||
};
|
};
|
||||||
const hasFiles = typedPayload.photo || typedPayload.nationalPhoto || typedPayload.driverCardPhoto;
|
const hasFiles =
|
||||||
|
typedPayload.photo ||
|
||||||
|
typedPayload.nationalPhoto ||
|
||||||
|
typedPayload.driverCardPhoto;
|
||||||
|
|
||||||
if (hasFiles) {
|
if (hasFiles) {
|
||||||
await driverService.updateWithImages(driver.id, typedPayload, token);
|
await driverService.updateWithImages(driver.id, typedPayload, token);
|
||||||
@@ -127,7 +199,8 @@ export default function DriverDetailPage() {
|
|||||||
showToast("success", "تم تحديث بيانات السائق بنجاح.");
|
showToast("success", "تم تحديث بيانات السائق بنجاح.");
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "تعذّر تحديث بيانات السائق.";
|
const message =
|
||||||
|
err instanceof Error ? err.message : "تعذّر تحديث بيانات السائق.";
|
||||||
showToast("error", message);
|
showToast("error", message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -153,7 +226,16 @@ export default function DriverDetailPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "6rem 0", color: "var(--color-text-muted)" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "6rem 0",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
<Spinner size="sm" className="text-blue-600" />
|
||||||
<span style={{ fontSize: 14 }}>جارٍ التحميل…</span>
|
<span style={{ fontSize: 14 }}>جارٍ التحميل…</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,85 +244,187 @@ export default function DriverDetailPage() {
|
|||||||
|
|
||||||
if (error || !driver) {
|
if (error || !driver) {
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 480, margin: "4rem auto", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "1.5rem", textAlign: "center" }}>
|
<div
|
||||||
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}>⚠ {error ?? "السائق غير موجود"}</p>
|
style={{
|
||||||
<button type="button" onClick={() => router.back()} style={{
|
maxWidth: 480,
|
||||||
marginTop: "1rem", height: 38, padding: "0 1.25rem",
|
margin: "4rem auto",
|
||||||
borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)",
|
borderRadius: "var(--radius-xl)",
|
||||||
background: "var(--color-surface)", fontSize: 13, fontWeight: 600,
|
border: "1px solid #FECACA",
|
||||||
color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)",
|
background: "#FEF2F2",
|
||||||
}}>
|
padding: "1.5rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}>
|
||||||
|
⚠ {error ?? "السائق غير موجود"}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
← رجوع
|
← رجوع
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
<section
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
|
||||||
|
>
|
||||||
|
<Toast notification={toast} onDismiss={() => setToast(null)} />
|
||||||
|
|
||||||
{toast && (
|
<header
|
||||||
<div style={{
|
style={{
|
||||||
borderRadius: "var(--radius-lg)",
|
borderRadius: "var(--radius-xl)",
|
||||||
border: `1px solid ${toast.type === "success" ? "#BBF7D0" : "#FECACA"}`,
|
border: "1px solid var(--color-border)",
|
||||||
background: toast.type === "success" ? "#DCFCE7" : "#FEF2F2",
|
background: "var(--color-surface)",
|
||||||
padding: "0.75rem 1.25rem",
|
padding: "1.5rem 2rem",
|
||||||
fontSize: 13,
|
boxShadow: "var(--shadow-card)",
|
||||||
fontWeight: 600,
|
}}
|
||||||
color: toast.type === "success" ? "#166534" : "#DC2626",
|
>
|
||||||
}}>
|
<Button
|
||||||
{toast.type === "success" ? "✓ " : "⚠ "}{toast.message}
|
variant="ghost"
|
||||||
</div>
|
size="sm"
|
||||||
)}
|
onClick={() => router.back()}
|
||||||
|
className="mb-4 !px-0"
|
||||||
<header style={{
|
>
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
<i
|
||||||
background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)",
|
className="ti ti-arrow-left"
|
||||||
}}>
|
style={{ fontSize: 14 }}
|
||||||
<button type="button" onClick={() => router.back()} style={{
|
aria-hidden="true"
|
||||||
display: "inline-flex", alignItems: "center", gap: 6,
|
/>
|
||||||
fontSize: 12, fontWeight: 600, color: "var(--color-text-muted)",
|
|
||||||
background: "none", border: "none", cursor: "pointer", padding: 0,
|
|
||||||
marginBottom: "1rem", fontFamily: "var(--font-sans)",
|
|
||||||
}}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<path d="M19 12H5M12 5l-7 7 7 7" />
|
|
||||||
</svg>
|
|
||||||
العودة إلى قائمة السائقين
|
العودة إلى قائمة السائقين
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||||
<div style={{
|
<div
|
||||||
width: 72, height: 72, borderRadius: "50%", overflow: "hidden", flexShrink: 0,
|
style={{
|
||||||
border: "2px solid var(--color-brand-200)", background: "var(--color-surface-muted)",
|
width: 72,
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
height: 72,
|
||||||
}}>
|
borderRadius: "50%",
|
||||||
|
overflow: "hidden",
|
||||||
|
flexShrink: 0,
|
||||||
|
border: "2px solid var(--color-brand-200)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{driver.photoUrl && !avatarError ? (
|
{driver.photoUrl && !avatarError ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={driver.photoUrl} alt={driver.name} style={{ width: "100%", height: "100%", objectFit: "cover" }} onError={() => setAvatarError(true)} />
|
<img
|
||||||
|
src={driver.photoUrl}
|
||||||
|
alt={driver.name}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
onError={() => setAvatarError(true)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: 28, fontWeight: 700, color: "var(--color-brand-600)" }}>{driver.name.charAt(0)}</span>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-brand-600)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{driver.name.charAt(0)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>ملف السائق</p>
|
<p
|
||||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>{driver.name}</h1>
|
style={{
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6, flexWrap: "wrap" }}>
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.3em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ملف السائق
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: "4px 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{driver.name}
|
||||||
|
</h1>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
marginTop: 6,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{driver.userName && (
|
{driver.userName && (
|
||||||
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>@{driver.userName}</span>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
@{driver.userName}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>{driver.phone}</span>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{driver.phone}
|
||||||
|
</span>
|
||||||
{statusConfig && (
|
{statusConfig && (
|
||||||
<span style={{
|
<span
|
||||||
borderRadius: "var(--radius-full)", border: `1px solid ${statusConfig.border}`,
|
style={{
|
||||||
background: statusConfig.bg, padding: "0.2rem 0.625rem",
|
borderRadius: "var(--radius-full)",
|
||||||
fontSize: 11, fontWeight: 700, color: statusConfig.color,
|
border: `1px solid ${statusConfig.border}`,
|
||||||
display: "inline-flex", alignItems: "center", gap: 5,
|
background: statusConfig.bg,
|
||||||
}}>
|
padding: "0.2rem 0.625rem",
|
||||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} />
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: statusConfig.color,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: statusConfig.dot,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{statusConfig.label}
|
{statusConfig.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -249,81 +433,157 @@ export default function DriverDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||||
<button type="button" onClick={() => setDeleteOpen(true)} style={{
|
<Button variant="danger" onClick={() => setDeleteOpen(true)}>
|
||||||
height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-lg)",
|
|
||||||
border: "1px solid #FECACA", background: "#FEF2F2",
|
|
||||||
fontSize: 13, fontWeight: 700, color: "#DC2626", cursor: "pointer", fontFamily: "var(--font-sans)",
|
|
||||||
}}>
|
|
||||||
حذف السائق
|
حذف السائق
|
||||||
</button>
|
</Button>
|
||||||
<button type="button" onClick={() => setEditOpen(true)} style={{
|
<Button variant="primary" onClick={() => setEditOpen(true)}>
|
||||||
height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-lg)",
|
<i
|
||||||
border: "none", background: "var(--color-brand-600)",
|
className="ti ti-edit"
|
||||||
fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer",
|
style={{ fontSize: 14 }}
|
||||||
display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)",
|
aria-hidden="true"
|
||||||
}}>
|
/>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
تعديل السائق
|
تعديل السائق
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: "1.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SectionCard title="البيانات الشخصية">
|
<SectionCard title="البيانات الشخصية">
|
||||||
<DetailRow label="الاسم الكامل" value={driver.name} />
|
<DetailRow label="الاسم الكامل" value={driver.name} />
|
||||||
<DetailRow label="رقم الجوال" value={driver.phone} mono />
|
<DetailRow label="رقم الجوال" value={driver.phone} mono />
|
||||||
<DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} />
|
<DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} />
|
||||||
<DetailRow label="العنوان" value={driver.address ?? "—"} />
|
<DetailRow label="العنوان" value={driver.address ?? "—"} />
|
||||||
<DetailRow label="الجنسية" value={driver.nationality ?? "—"} />
|
<DetailRow label="الجنسية" value={driver.nationality ?? "—"} />
|
||||||
<DetailRow label="الفرع" value={driver.branch?.name ?? "—"} />
|
<DetailRow label="الفرع" value={driver.branch?.name ?? "—"} />
|
||||||
<DetailRow label="نوع السائق" value={driver.driverType ?? "—"} />
|
<DetailRow label="نوع السائق" value={driver.driverType ?? "—"} />
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="الهوية والتأمينات">
|
<SectionCard title="الهوية والتأمينات">
|
||||||
<DetailRow label="نوع الهوية" value={driver.nationalIdType ? NATIONAL_ID_TYPE_MAP[driver.nationalIdType] : "—"} />
|
<DetailRow
|
||||||
<DetailRow label="رقم الهوية" value={driver.nationalId ?? "—"} mono />
|
label="نوع الهوية"
|
||||||
<DetailRow label="انتهاء الهوية" value={fmtDate(driver.nationalIdExpiry)} warn={isExpiringSoon(driver.nationalIdExpiry)} />
|
value={
|
||||||
|
driver.nationalIdType
|
||||||
|
? NATIONAL_ID_TYPE_MAP[driver.nationalIdType]
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="رقم الهوية"
|
||||||
|
value={driver.nationalId ?? "—"}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="انتهاء الهوية"
|
||||||
|
value={fmtDate(driver.nationalIdExpiry)}
|
||||||
|
warn={isExpiringSoon(driver.nationalIdExpiry)}
|
||||||
|
/>
|
||||||
<DetailRow label="رقم GOSI" value={driver.gosiNumber ?? "—"} mono />
|
<DetailRow label="رقم GOSI" value={driver.gosiNumber ?? "—"} mono />
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="بيانات رخصة القيادة">
|
<SectionCard title="بيانات رخصة القيادة">
|
||||||
<DetailRow label="رقم الرخصة" value={driver.licenseNumber ?? "—"} mono />
|
<DetailRow
|
||||||
|
label="رقم الرخصة"
|
||||||
|
value={driver.licenseNumber ?? "—"}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
<DetailRow label="نوع الرخصة" value={driver.licenseType ?? "—"} />
|
<DetailRow label="نوع الرخصة" value={driver.licenseType ?? "—"} />
|
||||||
<DetailRow label="انتهاء الرخصة" value={fmtDate(driver.licenseExpiry)} warn={isExpiringSoon(driver.licenseExpiry)} />
|
<DetailRow
|
||||||
|
label="انتهاء الرخصة"
|
||||||
|
value={fmtDate(driver.licenseExpiry)}
|
||||||
|
warn={isExpiringSoon(driver.licenseExpiry)}
|
||||||
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="بطاقة السائق">
|
<SectionCard title="بطاقة السائق">
|
||||||
<DetailRow label="رقم البطاقة" value={driver.driverCardNumber ?? "—"} mono />
|
<DetailRow
|
||||||
<DetailRow label="نوع البطاقة" value={driver.driverCardType ? DRIVER_CARD_TYPE_MAP[driver.driverCardType] : "—"} />
|
label="رقم البطاقة"
|
||||||
<DetailRow label="انتهاء البطاقة" value={fmtDate(driver.driverCardExpiry)} warn={isExpiringSoon(driver.driverCardExpiry)} />
|
value={driver.driverCardNumber ?? "—"}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="نوع البطاقة"
|
||||||
|
value={
|
||||||
|
driver.driverCardType
|
||||||
|
? DRIVER_CARD_TYPE_MAP[driver.driverCardType]
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="انتهاء البطاقة"
|
||||||
|
value={fmtDate(driver.driverCardExpiry)}
|
||||||
|
warn={isExpiringSoon(driver.driverCardExpiry)}
|
||||||
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="معلومات النظام">
|
<SectionCard title="معلومات النظام">
|
||||||
<DetailRow label="اسم المستخدم" value={driver.userName ?? "—"} mono />
|
<DetailRow
|
||||||
<DetailRow label="تاريخ الإضافة" value={fmtDate(driver.createdAt)} />
|
label="اسم المستخدم"
|
||||||
<DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} />
|
value={driver.userName ?? "—"}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="تاريخ الإضافة"
|
||||||
|
value={fmtDate(driver.createdAt)}
|
||||||
|
/>
|
||||||
|
<DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} />
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{driver.statusHistory && driver.statusHistory.length > 0 && (
|
{driver.statusHistory && driver.statusHistory.length > 0 && (
|
||||||
<SectionCard title="سجل الحالات">
|
<SectionCard title="سجل الحالات">
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
{driver.statusHistory.map((h) => {
|
{driver.statusHistory.map((h) => {
|
||||||
const s = DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active;
|
const s =
|
||||||
|
DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active;
|
||||||
return (
|
return (
|
||||||
<div key={h.id} style={{
|
<div
|
||||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
key={h.id}
|
||||||
borderRadius: "var(--radius-md)", border: `1px solid ${s.border}`,
|
style={{
|
||||||
background: s.bg, padding: "0.5rem 0.875rem",
|
display: "flex",
|
||||||
}}>
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: `1px solid ${s.border}`,
|
||||||
|
background: s.bg,
|
||||||
|
padding: "0.5rem 0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>{s.label}</span>
|
<span
|
||||||
{h.reason && <span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}>— {h.reason}</span>}
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: s.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
{h.reason && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
marginRight: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
— {h.reason}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(h.createdAt)}</span>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtDate(h.createdAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -333,23 +593,42 @@ export default function DriverDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SectionCard title="الصور والمستندات">
|
<SectionCard title="الصور والمستندات">
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1.25rem" }}>
|
<div
|
||||||
<PhotoCard url={driver.photoUrl} label="صورة السائق" />
|
style={{
|
||||||
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
|
display: "grid",
|
||||||
<PhotoCard url={driver.driverCardPhotoUrl} label="صورة بطاقة السائق" />
|
gridTemplateColumns: "repeat(3, 1fr)",
|
||||||
|
gap: "1.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PhotoCard url={driver.photoUrl} label="صورة السائق" />
|
||||||
|
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
|
||||||
|
<PhotoCard
|
||||||
|
url={driver.driverCardPhotoUrl}
|
||||||
|
label="صورة بطاقة السائق"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<div style={{
|
<div
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
style={{
|
||||||
background: "var(--color-surface)", boxShadow: "var(--shadow-card)", padding: "1.5rem",
|
borderRadius: "var(--radius-xl)",
|
||||||
}}>
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
padding: "1.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DriverReportPanel driverId={driver.id} />
|
<DriverReportPanel driverId={driver.id} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{editOpen && (
|
{editOpen && (
|
||||||
<DriverFormModal
|
<DriverFormModal
|
||||||
|
// CHANGE: added key — this page only ever edits a single driver
|
||||||
|
// (driverId from the route), but the key still protects against a
|
||||||
|
// remount bug if driver data is refetched with a new object identity
|
||||||
|
// after loadDriver() runs.
|
||||||
|
key={driver.id}
|
||||||
editDriver={driver}
|
editDriver={driver}
|
||||||
branches={[]}
|
branches={[]}
|
||||||
onClose={() => setEditOpen(false)}
|
onClose={() => setEditOpen(false)}
|
||||||
@@ -367,4 +646,4 @@ export default function DriverDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import {
|
import { Alert, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
||||||
Alert,
|
|
||||||
Spinner,
|
|
||||||
ArchiveButton,
|
|
||||||
ConfirmDialog,
|
|
||||||
Toast,
|
|
||||||
} from "@/src/Components/UI";
|
|
||||||
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
|
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
|
||||||
|
import { DriverTable } from "@/src/Components/Driver/DriverTable";
|
||||||
import { useDrivers } from "@/src/hooks/useDriver";
|
import { useDrivers } from "@/src/hooks/useDriver";
|
||||||
import {
|
import {
|
||||||
CreateDriverPayload,
|
CreateDriverPayload,
|
||||||
Driver,
|
Driver,
|
||||||
DRIVER_STATUS_MAP,
|
|
||||||
UpdateDriverPayload,
|
UpdateDriverPayload,
|
||||||
} from "@/src/types/driver";
|
} from "@/src/types/driver";
|
||||||
import { DriverDetailPanel, DriverFormModal } from "@/src/Components/Driver";
|
import { DriverDetailPanel, DriverFormModal } from "@/src/Components/Driver";
|
||||||
import Header from "@/src/Components/UI/Header";
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function fmtDate(iso?: string | null): string {
|
|
||||||
if (!iso) return "—";
|
|
||||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function expirySoon(iso?: string | null): boolean {
|
|
||||||
if (!iso) return false;
|
|
||||||
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Styles ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
overflow: "hidden",
|
|
||||||
boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Shared across header + rows — keep these in sync or columns will misalign.
|
|
||||||
const ROW_GRID_COLUMNS = "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr 0.8fr";
|
|
||||||
|
|
||||||
const iconBtnBase: React.CSSProperties = {
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
cursor: "pointer",
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Page Component ────────────────────────────────────────────────────────────
|
// ── Page Component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -88,7 +30,6 @@ export default function DriversPage() {
|
|||||||
updateDriver,
|
updateDriver,
|
||||||
deleteDriver,
|
deleteDriver,
|
||||||
notification,
|
notification,
|
||||||
clearNotification,
|
|
||||||
} = useDrivers();
|
} = useDrivers();
|
||||||
|
|
||||||
// ── Panel / modal state ───────────────────────────────────────────────────
|
// ── Panel / modal state ───────────────────────────────────────────────────
|
||||||
@@ -161,317 +102,165 @@ export default function DriversPage() {
|
|||||||
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
|
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
|
||||||
>
|
>
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<Header
|
<header
|
||||||
mainTitle="إدارة السائقين"
|
style={{
|
||||||
title="السائقون"
|
borderRadius: "var(--radius-xl)",
|
||||||
name="سائق"
|
border: "1px solid var(--color-border)",
|
||||||
state={{ total }}
|
background: "var(--color-surface)",
|
||||||
search={search}
|
padding: "1.5rem 2rem",
|
||||||
setSearch={handleSearch}
|
boxShadow: "var(--shadow-card)",
|
||||||
module=""
|
}}
|
||||||
setModule={() => {}}
|
>
|
||||||
setPage={setPage}
|
<p
|
||||||
isAudit={false}
|
style={{
|
||||||
onAdd={() => setFormDriver("new")}
|
fontSize: 11,
|
||||||
/>
|
letterSpacing: "0.3em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إدارة الكوادر
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
السائقون
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إجمالي{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{total}
|
||||||
|
</strong>{" "}
|
||||||
|
سائق مسجل
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.75rem",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ position: "relative", width: 288 }}>
|
||||||
|
<i
|
||||||
|
className="ti ti-search"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 12,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "var(--color-text-hint)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="بحث بالاسم أو الهاتف..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 40,
|
||||||
|
paddingRight: 36,
|
||||||
|
paddingLeft: 12,
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormDriver("new")}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.25rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "none",
|
||||||
|
background: "var(--color-brand-600)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="ti ti-plus"
|
||||||
|
style={{ fontSize: 15 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
إضافة سائق
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{/* ── Notifications ── */}
|
{/* ── Notifications ── */}
|
||||||
<Toast notification={notification} onDismiss={clearNotification} />
|
{notification && (
|
||||||
|
<Alert type={notification.type} message={notification.message} />
|
||||||
|
)}
|
||||||
{error && <Alert type="error" message={error} onClose={clearError} />}
|
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||||
|
|
||||||
{/* ── Table ── */}
|
{/* ── Table ── */}
|
||||||
<div style={cardStyle}>
|
<DriverTable
|
||||||
<div
|
drivers={drivers}
|
||||||
dir="rtl"
|
loading={loading}
|
||||||
style={{
|
search={search}
|
||||||
display: "grid",
|
page={page}
|
||||||
gridTemplateColumns: ROW_GRID_COLUMNS,
|
pages={pages}
|
||||||
...thStyle,
|
onPageChange={setPage}
|
||||||
}}
|
onRowClick={(d) => setSelectedDriverId(d.id)}
|
||||||
>
|
onEdit={handleEdit}
|
||||||
<span>السائق</span>
|
onDelete={handleDelete}
|
||||||
<span>الفرع</span>
|
/>
|
||||||
<span>الجنسية</span>
|
|
||||||
<span>انتهاء الرخصة</span>
|
|
||||||
<span>انتهاء الهوية</span>
|
|
||||||
<span>الحالة</span>
|
|
||||||
<span>إجراءات</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 12,
|
|
||||||
padding: "4rem 0",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
|
||||||
</div>
|
|
||||||
) : drivers.length === 0 ? (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
padding: "4rem 0",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
لا توجد نتائج {search && `لـ "${search}"`}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
|
||||||
{drivers.map((d, i) => {
|
|
||||||
const statusCfg =
|
|
||||||
DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive;
|
|
||||||
const licWarn = expirySoon(d.licenseExpiry);
|
|
||||||
const idWarn = expirySoon(
|
|
||||||
(d as Driver & { nationalIdExpiry?: string })
|
|
||||||
.nationalIdExpiry,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={d.id}
|
|
||||||
onClick={() => setSelectedDriverId(d.id)}
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: ROW_GRID_COLUMNS,
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.5rem",
|
|
||||||
padding: "0.875rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background:
|
|
||||||
i % 2 !== 0
|
|
||||||
? "var(--color-surface-muted)"
|
|
||||||
: "transparent",
|
|
||||||
fontSize: 13,
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "background 0.15s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) =>
|
|
||||||
(e.currentTarget.style.background =
|
|
||||||
"var(--color-surface-hover, #F8FAFC)")
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.background =
|
|
||||||
i % 2 !== 0
|
|
||||||
? "var(--color-surface-muted)"
|
|
||||||
: "transparent")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{d.name}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
marginTop: 2,
|
|
||||||
fontFamily: "var(--font-mono)",
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{d.phone}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
|
||||||
{d.branch?.name ?? "—"}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
|
||||||
{d.nationality ?? "—"}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: licWarn ? 600 : 400,
|
|
||||||
color: licWarn
|
|
||||||
? "#D97706"
|
|
||||||
: "var(--color-text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{licWarn && "⚠ "}
|
|
||||||
{fmtDate(d.licenseExpiry)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: idWarn ? 600 : 400,
|
|
||||||
color: idWarn
|
|
||||||
? "#D97706"
|
|
||||||
: "var(--color-text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{idWarn && "⚠ "}
|
|
||||||
{fmtDate(
|
|
||||||
(d as Driver & { nationalIdExpiry?: string })
|
|
||||||
.nationalIdExpiry,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 5,
|
|
||||||
borderRadius: "var(--radius-full)",
|
|
||||||
border: `1px solid ${statusCfg.border}`,
|
|
||||||
background: statusCfg.bg,
|
|
||||||
padding: "0.2rem 0.625rem",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: statusCfg.color,
|
|
||||||
width: "fit-content",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: statusCfg.dot,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{statusCfg.label}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* ── Inline row actions: edit / delete ──
|
|
||||||
stopPropagation is required on both buttons so the
|
|
||||||
click doesn't bubble up to the <li> onClick and
|
|
||||||
open the detail panel as well. */}
|
|
||||||
<div style={{ display: "flex", gap: "0.4rem" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="تعديل السائق"
|
|
||||||
title="تعديل"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEdit(d);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
...iconBtnBase,
|
|
||||||
border: "1px solid var(--color-brand-200)",
|
|
||||||
background: "var(--color-brand-50, #EFF6FF)",
|
|
||||||
color: "var(--color-brand-600)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
>
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="حذف السائق"
|
|
||||||
title="حذف"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDelete(d);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
...iconBtnBase,
|
|
||||||
border: "1px solid #FECACA",
|
|
||||||
background: "#FEF2F2",
|
|
||||||
color: "#DC2626",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" />
|
|
||||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Pagination ── */}
|
|
||||||
{pages > 1 && (
|
|
||||||
<div
|
|
||||||
dir="rtl"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
borderTop: "1px solid var(--color-border)",
|
|
||||||
padding: "0.875rem 1.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
صفحة{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
|
||||||
{page}
|
|
||||||
</strong>{" "}
|
|
||||||
من{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
|
||||||
{pages}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
label: "السابق",
|
|
||||||
action: () => setPage((p) => Math.max(1, p - 1)),
|
|
||||||
disabled: page === 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "التالي",
|
|
||||||
action: () => setPage((p) => Math.min(pages, p + 1)),
|
|
||||||
disabled: page === pages,
|
|
||||||
},
|
|
||||||
].map((btn) => (
|
|
||||||
<button
|
|
||||||
key={btn.label}
|
|
||||||
onClick={btn.action}
|
|
||||||
disabled={btn.disabled}
|
|
||||||
style={{
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface-muted)",
|
|
||||||
padding: "0.375rem 0.875rem",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
cursor: btn.disabled ? "not-allowed" : "pointer",
|
|
||||||
opacity: btn.disabled ? 0.4 : 1,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{btn.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Detail panel — key forces re-fetch after successful edit ── */}
|
{/* ── Detail panel — key forces re-fetch after successful edit ── */}
|
||||||
@@ -488,6 +277,11 @@ export default function DriversPage() {
|
|||||||
{/* ── Form modal ── */}
|
{/* ── Form modal ── */}
|
||||||
{formDriver !== null && (
|
{formDriver !== null && (
|
||||||
<DriverFormModal
|
<DriverFormModal
|
||||||
|
// CHANGE: added key — forces remount on switching between "new" and
|
||||||
|
// different edit targets, same fix applied to UserFormModal, so
|
||||||
|
// useForm's defaultValues (roleId/branchId/status/etc.) always match
|
||||||
|
// the correct driver instead of possibly reusing stale form state.
|
||||||
|
key={formDriver === "new" ? "new" : formDriver.id}
|
||||||
editDriver={formDriver === "new" ? null : formDriver}
|
editDriver={formDriver === "new" ? null : formDriver}
|
||||||
branches={[]}
|
branches={[]}
|
||||||
onClose={() => setFormDriver(null)}
|
onClose={() => setFormDriver(null)}
|
||||||
@@ -512,4 +306,4 @@ export default function DriversPage() {
|
|||||||
<ArchiveButton onClick={() => setArchiveOpen(true)} />
|
<ArchiveButton onClick={() => setArchiveOpen(true)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Alert, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
import { Alert, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
||||||
|
import { ORDER_STATUS_MAP } from "@/src/Components/Order/OrderDetailModel";
|
||||||
|
import { OrderTable } from "@/src/Components/Order/OrderTable";
|
||||||
import { ArchivedOrdersModal } from "@/src/Components/Order/archive/ArchivedOrdersModal";
|
import { ArchivedOrdersModal } from "@/src/Components/Order/archive/ArchivedOrdersModal";
|
||||||
import { useOrders } from "@/src/hooks/useOrder";
|
import { useOrders } from "@/src/hooks/useOrder";
|
||||||
import type { CreateOrderPayload, Order, UpdateOrderPayload } from "@/src/types/order";
|
import type { CreateOrderPayload, Order, UpdateOrderPayload } from "@/src/types/order";
|
||||||
import { OrderFormModal, OrderDetailPanel, OrderTable } from "@/src/Components/Order";
|
import { OrderFormModal , OrderDetailPanel} from "@/src/Components/Order";
|
||||||
import Header from "@/src/Components/UI/Header";
|
|
||||||
|
|
||||||
// ── Page Component ───────────────────────────────────────────────────────
|
// ── Page Component ───────────────────────────────────────────────────────
|
||||||
// Table markup/styles now live in src/Components/Order/orderTable.tsx;
|
|
||||||
// detail panel in OrderDetailModel.tsx (renamed from OrderDetailBanel.tsx);
|
|
||||||
// form modal unchanged in OrderFormModal.tsx. This page only wires state
|
|
||||||
// and handlers between the three.
|
|
||||||
|
|
||||||
export default function OrderComponent() {
|
export default function OrderComponent() {
|
||||||
const {
|
const {
|
||||||
@@ -93,18 +92,97 @@ export default function OrderComponent() {
|
|||||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<Header mainTitle="إدارة الطلبات"
|
<header style={{
|
||||||
title="الطلبات"
|
borderRadius: "var(--radius-xl)",
|
||||||
name="طلب"
|
border: "1px solid var(--color-border)",
|
||||||
state={{ total }}
|
background: "var(--color-surface)",
|
||||||
search={search}
|
padding: "1.5rem 2rem",
|
||||||
setSearch={handleSearch}
|
boxShadow: "var(--shadow-card)",
|
||||||
module=""
|
}}>
|
||||||
setModule={() => {}}
|
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
|
||||||
setPage={setPage}
|
إدارة الشحنات
|
||||||
isAudit={false}
|
</p>
|
||||||
onAdd={() => setFormOrder("new")}/>
|
<div style={{ marginTop: "0.75rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
|
الطلبات
|
||||||
|
</h1>
|
||||||
|
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||||
|
إجمالي{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>{total}</strong>{" "}
|
||||||
|
طلب مسجل
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
{/* Status filter — ported from app/dashboard/orders/page.tsx */}
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => handleStatusFilter(e.target.value)}
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
|
height: 40, borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13, color: "var(--color-text-secondary)",
|
||||||
|
padding: "0 0.75rem", outline: "none",
|
||||||
|
fontFamily: "var(--font-sans)", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">كل الحالات</option>
|
||||||
|
{Object.entries(ORDER_STATUS_MAP).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{v.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ position: "relative", width: 288 }}>
|
||||||
|
<i
|
||||||
|
className="ti ti-search"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", fontSize: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="رقم الشحنة أو المستلم..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
|
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13, color: "var(--color-text-primary)",
|
||||||
|
outline: "none", fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormOrder("new")}
|
||||||
|
style={{
|
||||||
|
height: 40, padding: "0 1.25rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "none",
|
||||||
|
background: "var(--color-brand-600)",
|
||||||
|
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex", alignItems: "center", gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="ti ti-plus" style={{ fontSize: 15 }} aria-hidden="true" />
|
||||||
|
إضافة طلب
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{/* ── Notifications: page-level error banner (e.g. failed list load)
|
{/* ── Notifications: page-level error banner (e.g. failed list load)
|
||||||
dismissible via clearError() from the hook. Action-result
|
dismissible via clearError() from the hook. Action-result
|
||||||
@@ -124,7 +202,7 @@ export default function OrderComponent() {
|
|||||||
page={page}
|
page={page}
|
||||||
pages={pages}
|
pages={pages}
|
||||||
setPage={setPage}
|
setPage={setPage}
|
||||||
onRowClick={setSelectedOrderId}
|
onRowClick={(orderId) => setSelectedOrderId(orderId)}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
@@ -183,5 +261,6 @@ export default function OrderComponent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Named export alongside the default, mirroring src/Components/Driver/index.ts's
|
// Named export alongside the default, mirroring src/Components/Driver/index.ts's
|
||||||
// pattern of re-exporting each piece for use elsewhere.
|
// pattern of re-exporting each piece for use elsewhere (e.g. a future
|
||||||
|
// app/dashboard/orders/page.tsx importing { OrderComponent }).
|
||||||
export { OrderComponent };
|
export { OrderComponent };
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect } from "react";
|
||||||
import { useDashboardSummary } from "@/src/hooks/useDashboardSummary";
|
|
||||||
import { clearAuth, getStoredUser } from "@/src/lib/auth";
|
import { clearAuth, getStoredUser } from "@/src/lib/auth";
|
||||||
import { Spinner, Alert } from "@/src/Components/UI";
|
import { Spinner, Alert } from "@/src/Components/UI";
|
||||||
|
import { useDashboardOverview } from "@/src/hooks/useDashboardOverview";
|
||||||
|
import {
|
||||||
|
KpiSection,
|
||||||
|
AlertsSection,
|
||||||
|
RecentActivityPanel,
|
||||||
|
TrendChartsPanel,
|
||||||
|
QuickAccessFooter,
|
||||||
|
} from "@/src/Components/Dashboard";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data, error, loading } = useDashboardSummary();
|
const { data, error, loading } = useDashboardOverview();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUser = getStoredUser();
|
const storedUser = getStoredUser();
|
||||||
@@ -20,20 +26,8 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const stats = data?.stats;
|
|
||||||
const alerts = data?.alerts;
|
|
||||||
const activeTrips = data?.activeTrips || [];
|
const activeTrips = data?.activeTrips || [];
|
||||||
|
|
||||||
const summaryCards = useMemo(
|
|
||||||
() => [
|
|
||||||
{ label: "العملاء", value: stats?.clients ?? 0, accent: "#06B6D4" },
|
|
||||||
{ label: "الطلبات", value: stats?.orders ?? 0, accent: "#A78BFA" },
|
|
||||||
{ label: "الرحلات", value: stats?.trips ?? 0, accent: "#34D399" },
|
|
||||||
{ label: "المركبات و السائقين ", value: (stats?.cars ?? 0) + (stats?.drivers ?? 0), accent: "#FBBF24" },
|
|
||||||
],
|
|
||||||
[stats],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col gap-6">
|
<section className="flex flex-col gap-6">
|
||||||
|
|
||||||
@@ -57,15 +51,14 @@ export default function DashboardPage() {
|
|||||||
ذكاء الأسطول في لمحة سريعة
|
ذكاء الأسطول في لمحة سريعة
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ marginTop: "0.75rem", maxWidth: 680, color: "var(--color-text-dark-muted)", fontSize: 14, lineHeight: 1.6, textAlign: "start" }}>
|
<p style={{ marginTop: "0.75rem", maxWidth: 680, color: "var(--color-text-dark-muted)", fontSize: 14, lineHeight: 1.6, textAlign: "start" }}>
|
||||||
عرض واضح لطلبات الأسطول وتنبيهات السلامة وتقدم الرحلات.
|
عرض واضح لكل وحدة من وحدات المنصة، مع تنبيهات فورية واتجاهات الأداء.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── Loading ── */}
|
{/* ── Loading ── */}
|
||||||
{loading && (
|
{loading && !data && (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12, color: "var(--color-text-dark-muted)" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12, color: "var(--color-text-dark-muted)" }}>
|
||||||
<Spinner size="sm" className="text-cyan-400" />
|
<Spinner size="sm" className="text-cyan-400" />
|
||||||
<span style={{ fontSize: 14 }}>جارٍ تحميل البيانات…</span>
|
<span style={{ fontSize: 14 }}>جارٍ تحميل البيانات…</span>
|
||||||
@@ -74,163 +67,93 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* ── Error ── */}
|
{/* ── Error ── */}
|
||||||
{error && (
|
{error && (
|
||||||
<Alert type="error" message={error}
|
<Alert type="error" message={error} className="border-rose-400/30 bg-rose-500/10 text-rose-100" />
|
||||||
className="border-rose-400/30 bg-rose-500/10 text-rose-100" />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && (
|
{/* ── KPI Section: total / active / pending per entity (9 cards) ── */}
|
||||||
<>
|
<KpiSection entities={data?.entities ?? []} loading={loading && !data} />
|
||||||
{/* ── Summary cards ── */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{summaryCards.map((item) => (
|
|
||||||
<article
|
|
||||||
key={item.label}
|
|
||||||
style={{
|
|
||||||
borderRadius: "var(--radius-2xl)",
|
|
||||||
border: "1px solid var(--color-border-dark)",
|
|
||||||
background: "var(--color-surface-dark-card)",
|
|
||||||
padding: "1.25rem",
|
|
||||||
boxShadow: "var(--shadow-card)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ height: 3, borderRadius: 999, background: item.accent, marginBottom: "1rem" }} />
|
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
|
||||||
{item.label}
|
|
||||||
</p>
|
|
||||||
<p style={{ fontSize: "2.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>
|
|
||||||
{item.value}
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Active trips + alerts ──
|
{/* ── Alerts Section: urgent cross-entity issues ── */}
|
||||||
Column order in the template below stays [trips, alerts] —
|
<AlertsSection alerts={data?.alerts ?? []} loading={loading && !data} />
|
||||||
same reading order as before. In RTL this now renders
|
|
||||||
trips on the right (read first) and alerts on the left,
|
{/* ── Insights Row: recent activity log + trend charts ── */}
|
||||||
which is the correct mirror; no class change needed (see
|
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||||
grid + RTL note in the project report). */}
|
<RecentActivityPanel logs={data?.recentActivity ?? []} loading={loading && !data} />
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
<TrendChartsPanel series={data?.trends ?? []} loading={loading && !data} />
|
||||||
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
|
{/* ── Quick access to every entity page (minimizes navigation clicks) ── */}
|
||||||
|
<QuickAccessFooter />
|
||||||
|
|
||||||
|
{/* ── Active trips (kept from the previous layout — live operational detail) ── */}
|
||||||
|
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#67E8F9", textAlign: "start" }}>الرحلات النشطة</p>
|
||||||
|
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>تقدم الرحلات</h2>
|
||||||
|
</div>
|
||||||
|
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(52,211,153,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#008000" }}>
|
||||||
|
مباشر
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
|
{activeTrips.length ? activeTrips.map((trip) => (
|
||||||
|
<div key={trip.id} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "1rem" }}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#67E8F9", textAlign: "start" }}>الرحلات النشطة</p>
|
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
|
||||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>تقدم الرحلات</h2>
|
<span className="ltr-embed">{trip.tripNumber}</span>
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>{trip.title}</p>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(52,211,153,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#008000" }}>
|
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(103,232,249,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#CFFAFE" }}>
|
||||||
مباشر
|
{trip.progress}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
<div style={{ marginTop: "0.75rem", height: 6, borderRadius: 999, background: "rgba(255,255,255,0.08)" }}>
|
||||||
{activeTrips.length ? activeTrips.map((trip) => (
|
<div style={{ height: 6, borderRadius: 999, width: `${trip.progress}%`, background: "linear-gradient(90deg,#06B6D4,#34D399)" }} />
|
||||||
<div key={trip.id} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "1rem" }}>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
|
|
||||||
<span className="ltr-embed">{trip.tripNumber}</span>
|
|
||||||
</p>
|
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>{trip.title}</p>
|
|
||||||
</div>
|
|
||||||
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(103,232,249,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#CFFAFE" }}>
|
|
||||||
{trip.progress}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: "0.75rem", height: 6, borderRadius: 999, background: "rgba(255,255,255,0.08)" }}>
|
|
||||||
{/*
|
|
||||||
Progress fill: width % growing from the bar's
|
|
||||||
start edge is correct in both directions since
|
|
||||||
`width` itself is non-directional — the bar's
|
|
||||||
own inline-start (right, in RTL) is where the
|
|
||||||
fill begins, matching how the rest of the RTL
|
|
||||||
layout fills from the right. No change needed.
|
|
||||||
*/}
|
|
||||||
<div style={{ height: 6, borderRadius: 999, width: `${trip.progress}%`, background: "linear-gradient(90deg,#06B6D4,#34D399)" }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)) : (
|
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>لا توجد رحلات نشطة حالياً.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
|
)) : (
|
||||||
|
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>لا توجد رحلات نشطة حالياً.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
{/* ── Security + endpoints ── */}
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#FCD34D", textAlign: "start" }}>تنبيهات الالتزام</p>
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>التجديدات القادمة</h2>
|
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#C4B5FD", textAlign: "start" }}>الأمان</p>
|
||||||
{(alerts?.expiringCars || []).slice(0, 3).map((item, i) => (
|
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>لمحة الحساب</h2>
|
||||||
<div key={`car-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,191,36,0.20)", background: "rgba(251,191,36,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
|
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
{String((item as Record<string, unknown>).message || "Vehicle expiry alert")}
|
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
||||||
</div>
|
آخر تسجيل دخول: <span className="ltr-embed">{data?.accountSecurity?.lastLogin || "—"}</span>
|
||||||
))}
|
</div>
|
||||||
{(alerts?.expiringDrivers || []).slice(0, 3).map((item, i) => (
|
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
||||||
<div key={`driver-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,113,133,0.20)", background: "rgba(244,63,94,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
|
بيانات الجهاز: <span className="ltr-embed">{data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"}</span>
|
||||||
{String((item as Record<string, unknown>).message || "Driver expiry alert")}
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(alerts?.upcomingMaint || []).slice(0, 3).map((item, i) => (
|
|
||||||
<div key={`maint-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(103,232,249,0.20)", background: "rgba(103,232,249,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
|
|
||||||
{String((item as Record<string, unknown>).message || "Maintenance alert")}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!(alerts?.expiringCars?.length || alerts?.expiringDrivers?.length || alerts?.upcomingMaint?.length) && (
|
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>لا توجد تنبيهات حالياً.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
{/* ── Security + endpoints ── */}
|
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#6EE7B7", textAlign: "start" }}>خريطة الخلفية</p>
|
||||||
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>النقاط النهائية</h2>
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#C4B5FD", textAlign: "start" }}>الأمان</p>
|
<ul style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem", listStyle: "none", padding: 0 }}>
|
||||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>لمحة الحساب</h2>
|
{[
|
||||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
"/v1/dashboard/overview — نظرة عامة تشغيلية",
|
||||||
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
"/v1/client — إدارة العملاء",
|
||||||
آخر تسجيل دخول: <span className="ltr-embed">{data?.accountSecurity?.lastLogin || "—"}</span>
|
"/v1/orders — دورة شحن الطلبات",
|
||||||
</div>
|
"/v1/trip — تنظيم الرحلات",
|
||||||
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
].map((ep) => (
|
||||||
{/*
|
<li key={ep} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
||||||
JSON.stringify output is structurally LTR (braces,
|
<span className="ltr-embed">{ep.split(" — ")[0]}</span>
|
||||||
colons, commas read left-to-right regardless of
|
{" — "}
|
||||||
locale). Wrapping it in .ltr-embed prevents the
|
{ep.split(" — ")[1]}
|
||||||
surrounding RTL paragraph from reordering the
|
</li>
|
||||||
punctuation — without this, nested objects can
|
))}
|
||||||
render with their braces visually swapped.
|
</ul>
|
||||||
*/}
|
</article>
|
||||||
بيانات الجهاز: <span className="ltr-embed">{data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"}</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#6EE7B7", textAlign: "start" }}>خريطة الخلفية</p>
|
|
||||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>النقاط النهائية</h2>
|
|
||||||
<ul style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem", listStyle: "none", padding: 0 }}>
|
|
||||||
{[
|
|
||||||
"/v1/dashboard/summary — نظرة عامة تشغيلية",
|
|
||||||
"/v1/client — إدارة العملاء",
|
|
||||||
"/v1/orders — دورة شحن الطلبات",
|
|
||||||
"/v1/trip — تنظيم الرحلات",
|
|
||||||
].map((ep) => (
|
|
||||||
<li key={ep} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
|
||||||
{/*
|
|
||||||
Each entry mixes an LTR API path with an Arabic
|
|
||||||
description. Splitting on the em-dash and
|
|
||||||
isolating only the path keeps the route string
|
|
||||||
from reading backwards while letting the Arabic
|
|
||||||
half flow naturally in the paragraph's own
|
|
||||||
direction.
|
|
||||||
*/}
|
|
||||||
<span className="ltr-embed">{ep.split(" — ")[0]}</span>
|
|
||||||
{" — "}
|
|
||||||
{ep.split(" — ")[1]}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,6 @@ export default function ProfilePage() {
|
|||||||
const { user, loading, error } = useCurrentUser();
|
const { user, loading, error } = useCurrentUser();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
// حالة تحميل بسيطة تتماشى مع سبينر الصور اللي عملتها قبل كده في الصفحات التانية
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[60vh] items-center justify-center">
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||||
@@ -27,12 +26,10 @@ export default function ProfilePage() {
|
|||||||
{ label: "اسم المستخدم", value: user.userName ?? "—" },
|
{ label: "اسم المستخدم", value: user.userName ?? "—" },
|
||||||
{ label: "البريد الإلكتروني", value: user.email ?? "—" },
|
{ label: "البريد الإلكتروني", value: user.email ?? "—" },
|
||||||
{ label: "رقم الهاتف", value: user.phone },
|
{ label: "رقم الهاتف", value: user.phone },
|
||||||
{ label: "الحالة", value: user.isActive ? "نشط" : "غير نشط" },
|
|
||||||
{ label: "تاريخ الإنشاء", value: new Date(user.createdAt).toLocaleDateString("ar-SA") },
|
{ label: "تاريخ الإنشاء", value: new Date(user.createdAt).toLocaleDateString("ar-SA") },
|
||||||
{ label: "آخر تحديث", value: new Date(user.updatedAt).toLocaleDateString("ar-SA") },
|
{ label: "آخر تحديث", value: new Date(user.updatedAt).toLocaleDateString("ar-SA") },
|
||||||
];
|
];
|
||||||
|
|
||||||
// تجميع الصلاحيات حسب الموديول عشان تبقى منظمة وسهلة القراءة
|
|
||||||
const permissionsByModule = (user.role?.permissions ?? []).reduce<Record<string, string[]>>(
|
const permissionsByModule = (user.role?.permissions ?? []).reduce<Record<string, string[]>>(
|
||||||
(acc, entry) => {
|
(acc, entry) => {
|
||||||
const mod = entry.permission.module || "أخرى";
|
const mod = entry.permission.module || "أخرى";
|
||||||
@@ -43,81 +40,98 @@ export default function ProfilePage() {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const modules = Object.keys(permissionsByModule);
|
const modules = Object.keys(permissionsByModule);
|
||||||
|
const totalPermissions = Object.values(permissionsByModule).reduce((n, arr) => n + arr.length, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl space-y-6 px-4 py-10" dir="rtl">
|
<div className="min-h-screen bg-slate-50" dir="rtl">
|
||||||
{/* بطاقة البيانات الأساسية */}
|
{/* top bar: user info */}
|
||||||
<div className="rounded-2xl bg-white p-6 shadow-md">
|
<div className="border-b border-slate-200 bg-white">
|
||||||
<div className="mb-6 flex flex-col items-center gap-3">
|
<div className="mx-auto flex w-full max-w-6xl flex-col items-center gap-4 px-6 py-10 sm:flex-row sm:items-center">
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-blue-50 text-2xl font-semibold text-blue-600 ring-2 ring-blue-100">
|
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xl font-semibold text-white">
|
||||||
{user.name?.trim().charAt(0) ?? "?"}
|
{user.name?.trim().charAt(0) ?? "?"}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-base font-semibold text-slate-900">{user.name}</h1>
|
<div className="flex-1 text-center sm:text-right">
|
||||||
{user.role?.name && (
|
<h1 className="text-lg font-semibold text-slate-900">{user.name}</h1>
|
||||||
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">
|
<p className="mt-0.5 text-sm text-slate-500">{user.email ?? user.phone}</p>
|
||||||
{user.role.name}
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{user.role?.name && (
|
||||||
|
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">
|
||||||
|
{user.role.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||||
|
user.isActive ? "bg-emerald-50 text-emerald-700" : "bg-slate-100 text-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.isActive ? "نشط" : "غير نشط"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* عرض البيانات بشكل read-only بدون أي فورم أو زرار تعديل */}
|
|
||||||
<dl className="divide-y divide-slate-100">
|
|
||||||
{fields.map((f) => (
|
|
||||||
<div key={f.label} className="flex items-center justify-between py-2.5 text-[13px]">
|
|
||||||
<dt className="text-slate-500">{f.label}</dt>
|
|
||||||
<dd className="font-medium text-slate-800">{f.value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* بطاقة الدور والصلاحيات */}
|
{/* main content, full width */}
|
||||||
{user.role && (
|
<div className="mx-auto w-full max-w-6xl px-6 py-8">
|
||||||
<div className="rounded-2xl bg-white p-6 shadow-md">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<div className="mb-4">
|
{/* account details */}
|
||||||
<h2 className="text-sm font-semibold text-slate-900">الدور والصلاحيات</h2>
|
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-100 lg:col-span-1">
|
||||||
{user.role.description && (
|
<h2 className="mb-4 text-sm font-semibold text-slate-900">بيانات الحساب</h2>
|
||||||
<p className="mt-1 text-[13px] text-slate-500">{user.role.description}</p>
|
<dl className="divide-y divide-slate-100">
|
||||||
)}
|
{fields.map((f) => (
|
||||||
|
<div key={f.label} className="flex items-center justify-between py-2.5 text-[13px]">
|
||||||
|
<dt className="text-slate-500">{f.label}</dt>
|
||||||
|
<dd className="font-medium text-slate-800">{f.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modules.length === 0 ? (
|
{/* role and permissions */}
|
||||||
<p className="text-[13px] text-slate-400">لا توجد صلاحيات مسجّلة لهذا الدور.</p>
|
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-100 lg:col-span-2">
|
||||||
) : (
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
{modules.map((mod) => (
|
<h2 className="text-sm font-semibold text-slate-900">الدور والصلاحيات</h2>
|
||||||
<details
|
{user.role?.description && (
|
||||||
key={mod}
|
<p className="mt-1 text-[13px] text-slate-500">{user.role.description}</p>
|
||||||
className="group rounded-lg border border-slate-100 open:bg-slate-50"
|
)}
|
||||||
open
|
</div>
|
||||||
>
|
{modules.length > 0 && (
|
||||||
<summary className="flex cursor-pointer list-none items-center justify-between px-3 py-2.5 text-[13px] font-medium text-slate-700">
|
<span className="rounded-full bg-slate-100 px-3 py-1 text-[11px] font-medium text-slate-500">
|
||||||
<span className="flex items-center gap-2">
|
{totalPermissions} صلاحية
|
||||||
{mod}
|
</span>
|
||||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-normal text-slate-500">
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modules.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-slate-400">لا توجد صلاحيات مسجّلة لهذا الدور.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{modules.map((mod) => (
|
||||||
|
<div key={mod} className="rounded-xl border border-slate-100 bg-slate-50/60 p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-[13px] font-medium text-slate-700">{mod}</span>
|
||||||
|
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] font-normal text-slate-500 ring-1 ring-slate-100">
|
||||||
{permissionsByModule[mod].length}
|
{permissionsByModule[mod].length}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<span className="text-slate-400 transition-transform group-open:rotate-180">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
▾
|
{permissionsByModule[mod].map((name) => (
|
||||||
</span>
|
<span
|
||||||
</summary>
|
key={name}
|
||||||
<div className="flex flex-wrap gap-2 px-3 pb-3 pt-1">
|
className="rounded-md bg-blue-50 px-2 py-1 text-[11px] font-medium text-blue-700"
|
||||||
{permissionsByModule[mod].map((name) => (
|
>
|
||||||
<span
|
{name}
|
||||||
key={name}
|
</span>
|
||||||
className="rounded-md bg-blue-50 px-2.5 py-1 text-[12px] font-medium text-blue-700"
|
))}
|
||||||
>
|
</div>
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
7
app/dashboard/role_permission/page.tsx
Normal file
7
app/dashboard/role_permission/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
return (
|
||||||
|
<div>page</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,22 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// app/dashboard/trips/[tripId]/page.tsx
|
|
||||||
// CHANGE: TripFormModal now imported from the barrel (@/src/Components/Trip)
|
|
||||||
// instead of a direct path — avoids the Tripformmodal.tsx case-sensitivity
|
|
||||||
// mismatch that breaks on case-sensitive filesystems (Linux/Docker builds).
|
|
||||||
// CHANGE: loadTrip's catch block now uses the same extractApiMessage helper
|
|
||||||
// already defined in this file (and used by handleEditSubmit/handleConfirmDelete)
|
|
||||||
// instead of `err instanceof Error ? err.message : ...` — so backend
|
|
||||||
// validation messages surface correctly here too, not just on edit/delete.
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { ConfirmDialog, Spinner } from "@/src/Components/UI";
|
import { Button, ConfirmDialog, Spinner } from "@/src/Components/UI";
|
||||||
import { TripFormModal } from "@/src/Components/Trip";
|
import { TripFormModal } from "@/src/Components/Trip";
|
||||||
import { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel";
|
import { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel";
|
||||||
import { tripService } from "@/src/services/trip.service";
|
import { tripService } from "@/src/services/trip.service";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import type { Trip, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip";
|
import type {
|
||||||
|
Trip,
|
||||||
|
CreateTripPayload,
|
||||||
|
UpdateTripPayload,
|
||||||
|
} from "@/src/types/trip";
|
||||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||||
import { Toast, type ToastNotification } from "@/src/Components/UI/Toast";
|
import { Toast, type ToastNotification } from "@/src/Components/UI/Toast";
|
||||||
|
|
||||||
@@ -29,14 +24,20 @@ function extractApiMessage(err: unknown, fallback: string): string {
|
|||||||
|
|
||||||
if (err && typeof err === "object") {
|
if (err && typeof err === "object") {
|
||||||
const e = err as Record<string, unknown>;
|
const e = err as Record<string, unknown>;
|
||||||
const responseData = (e["response"] as Record<string, unknown> | undefined)?.["data"];
|
const responseData = (
|
||||||
|
e["response"] as Record<string, unknown> | undefined
|
||||||
|
)?.["data"];
|
||||||
if (responseData && typeof responseData === "object") {
|
if (responseData && typeof responseData === "object") {
|
||||||
const rd = responseData as Record<string, unknown>;
|
const rd = responseData as Record<string, unknown>;
|
||||||
if (typeof rd["message"] === "string" && rd["message"].trim()) return rd["message"];
|
if (typeof rd["message"] === "string" && rd["message"].trim())
|
||||||
if (Array.isArray(rd["message"])) return (rd["message"] as string[]).join(" — ");
|
return rd["message"];
|
||||||
if (typeof rd["error"] === "string" && rd["error"].trim()) return rd["error"];
|
if (Array.isArray(rd["message"]))
|
||||||
|
return (rd["message"] as string[]).join(" — ");
|
||||||
|
if (typeof rd["error"] === "string" && rd["error"].trim())
|
||||||
|
return rd["error"];
|
||||||
}
|
}
|
||||||
if (typeof e["message"] === "string" && e["message"].trim()) return e["message"];
|
if (typeof e["message"] === "string" && e["message"].trim())
|
||||||
|
return e["message"];
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -126,7 +127,13 @@ function DetailRow({
|
|||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -169,10 +176,24 @@ function StatCard({
|
|||||||
gap: 4,
|
gap: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)", letterSpacing: "0.05em" }}>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 22, fontWeight: 800, color, fontFamily: "var(--font-mono)" }}>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 800,
|
||||||
|
color,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,17 +207,19 @@ export default function TripDetailPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const tripId = params?.tripId as string;
|
const tripId = params?.tripId as string;
|
||||||
|
|
||||||
const [trip, setTrip] = useState<Trip | null>(null);
|
const [trip, setTrip] = useState<Trip | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// ── Modal state ───────────────────────────────────────────────────────────
|
// ── Modal state ───────────────────────────────────────────────────────────
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
// ── Notifications (standalone — no longer borrowed from the list hook) ───
|
// ── Notifications (standalone — no longer borrowed from the list hook) ───
|
||||||
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
const [notification, setNotification] = useState<ToastNotification | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const notify = useCallback((n: ToastNotification) => {
|
const notify = useCallback((n: ToastNotification) => {
|
||||||
@@ -210,7 +233,12 @@ export default function TripDetailPage() {
|
|||||||
setNotification(null);
|
setNotification(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// ── Load trip ─────────────────────────────────────────────────────────────
|
// ── Load trip ─────────────────────────────────────────────────────────────
|
||||||
const loadTrip = useCallback(async () => {
|
const loadTrip = useCallback(async () => {
|
||||||
@@ -239,12 +267,19 @@ export default function TripDetailPage() {
|
|||||||
if (!trip) return false;
|
if (!trip) return false;
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const updated = await tripService.update(trip.id, payload as UpdateTripPayload, token);
|
const updated = await tripService.update(
|
||||||
|
trip.id,
|
||||||
|
payload as UpdateTripPayload,
|
||||||
|
token,
|
||||||
|
);
|
||||||
setTrip(updated);
|
setTrip(updated);
|
||||||
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
|
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") });
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message: extractApiMessage(err, "تعذّر تحديث الرحلة."),
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -260,7 +295,10 @@ export default function TripDetailPage() {
|
|||||||
await tripService.delete(trip.id, token);
|
await tripService.delete(trip.id, token);
|
||||||
router.push("/dashboard/trips");
|
router.push("/dashboard/trips");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر حذف الرحلة.") });
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message: extractApiMessage(err, "تعذّر حذف الرحلة."),
|
||||||
|
});
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
}, [trip, router, notify]);
|
}, [trip, router, notify]);
|
||||||
@@ -304,25 +342,14 @@ export default function TripDetailPage() {
|
|||||||
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}>
|
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}>
|
||||||
⚠ {error ?? "الرحلة غير موجودة"}
|
⚠ {error ?? "الرحلة غير موجودة"}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
style={{
|
style={{ marginTop: "1rem" }}
|
||||||
marginTop: "1rem",
|
|
||||||
height: 38,
|
|
||||||
padding: "0 1.25rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
← رجوع
|
← رجوع
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -333,8 +360,9 @@ export default function TripDetailPage() {
|
|||||||
{/* ── Toast notification ── */}
|
{/* ── Toast notification ── */}
|
||||||
<Toast notification={notification} onDismiss={dismissNotification} />
|
<Toast notification={notification} onDismiss={dismissNotification} />
|
||||||
|
|
||||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
<section
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
|
||||||
|
>
|
||||||
{/* ── Page header ── */}
|
{/* ── Page header ── */}
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
@@ -346,31 +374,34 @@ export default function TripDetailPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Back link */}
|
{/* Back link */}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
height: "auto",
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
background: "none",
|
||||||
marginBottom: "1rem",
|
marginBottom: "1rem",
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<i
|
||||||
<path d="M19 12H5M12 5l-7 7 7 7" />
|
className="ti ti-arrow-left"
|
||||||
</svg>
|
style={{ fontSize: 14 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
العودة إلى قائمة الرحلات
|
العودة إلى قائمة الرحلات
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Icon + title */}
|
{/* Icon + title */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||||
<div
|
<div
|
||||||
@@ -387,20 +418,56 @@ export default function TripDetailPage() {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 28, fontWeight: 700, color: "var(--color-brand-600)" }}>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-brand-600)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{trip.title.charAt(0)}
|
{trip.title.charAt(0)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.3em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
ملف الرحلة
|
ملف الرحلة
|
||||||
</p>
|
</p>
|
||||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: "4px 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{trip.title}
|
{trip.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6, flexWrap: "wrap" }}>
|
<div
|
||||||
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
marginTop: 6,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
#{trip.tripNumber}
|
#{trip.tripNumber}
|
||||||
</span>
|
</span>
|
||||||
{statusConfig && (
|
{statusConfig && (
|
||||||
@@ -418,7 +485,15 @@ export default function TripDetailPage() {
|
|||||||
gap: 5,
|
gap: 5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} />
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: statusConfig.dot,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{statusConfig.label}
|
{statusConfig.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -465,10 +540,11 @@ export default function TripDetailPage() {
|
|||||||
fontFamily: "var(--font-sans)",
|
fontFamily: "var(--font-sans)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<i
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
className="ti ti-edit"
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
style={{ fontSize: 14 }}
|
||||||
</svg>
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
تعديل الرحلة
|
تعديل الرحلة
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -476,13 +552,35 @@ export default function TripDetailPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── Stats row ── */}
|
{/* ── Stats row ── */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: "0.75rem" }}>
|
<div
|
||||||
<StatCard label="المجمّع" value={trip.collectedCount} color="var(--color-brand-600)" />
|
style={{
|
||||||
<StatCard label="المُسلَّم" value={trip.deliveredCount} color="#16A34A" />
|
display: "grid",
|
||||||
<StatCard label="المُرتجع" value={trip.returnedCount} color="#D97706" />
|
gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatCard
|
||||||
|
label="المجمّع"
|
||||||
|
value={trip.collectedCount}
|
||||||
|
color="var(--color-brand-600)"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="المُسلَّم"
|
||||||
|
value={trip.deliveredCount}
|
||||||
|
color="#16A34A"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="المُرتجع"
|
||||||
|
value={trip.returnedCount}
|
||||||
|
color="#D97706"
|
||||||
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="النقد المحصّل"
|
label="النقد المحصّل"
|
||||||
value={trip.totalCashCollected != null ? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س` : "—"}
|
value={
|
||||||
|
trip.totalCashCollected != null
|
||||||
|
? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
color="#7C3AED"
|
color="#7C3AED"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,45 +595,77 @@ export default function TripDetailPage() {
|
|||||||
>
|
>
|
||||||
{/* Trip Info */}
|
{/* Trip Info */}
|
||||||
<SectionCard title="بيانات الرحلة">
|
<SectionCard title="بيانات الرحلة">
|
||||||
<DetailRow label="وقت البدء" value={fmtDateTime(trip.startTime)} />
|
<DetailRow label="وقت البدء" value={fmtDateTime(trip.startTime)} />
|
||||||
<DetailRow label="وقت الانتهاء" value={fmtDateTime(trip.endTime)} />
|
<DetailRow label="وقت الانتهاء" value={fmtDateTime(trip.endTime)} />
|
||||||
<DetailRow label="الفرع" value={trip.branch?.name ?? "—"} />
|
<DetailRow label="الفرع" value={trip.branch?.name ?? "—"} />
|
||||||
{trip.notes && <DetailRow label="ملاحظات" value={trip.notes} />}
|
{trip.notes && <DetailRow label="ملاحظات" value={trip.notes} />}
|
||||||
{trip.endReason && <DetailRow label="سبب الإنهاء" value={trip.endReason} warn />}
|
{trip.endReason && (
|
||||||
|
<DetailRow label="سبب الإنهاء" value={trip.endReason} warn />
|
||||||
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{/* Driver */}
|
{/* Driver */}
|
||||||
{trip.driver && (
|
{trip.driver && (
|
||||||
<SectionCard title="بيانات السائق">
|
<SectionCard title="بيانات السائق">
|
||||||
<DetailRow label="الاسم" value={trip.driver.name} />
|
<DetailRow label="الاسم" value={trip.driver.name} />
|
||||||
<DetailRow label="الجوال" value={trip.driver.phone} mono />
|
<DetailRow label="الجوال" value={trip.driver.phone} mono />
|
||||||
<DetailRow label="اسم المستخدم" value={trip.driver.userName ?? "—"} mono />
|
<DetailRow
|
||||||
<DetailRow label="البريد" value={trip.driver.email ?? "—"} />
|
label="اسم المستخدم"
|
||||||
<DetailRow label="الجنسية" value={trip.driver.nationality ?? "—"} />
|
value={trip.driver.userName ?? "—"}
|
||||||
<DetailRow label="رخصة القيادة" value={trip.driver.licenseNumber ?? "—"} mono />
|
mono
|
||||||
<DetailRow label="بطاقة السائق" value={trip.driver.driverCardNumber ?? "—"} mono />
|
/>
|
||||||
<DetailRow label="رقم GOSI" value={trip.driver.gosiNumber ?? "—"} mono />
|
<DetailRow label="البريد" value={trip.driver.email ?? "—"} />
|
||||||
|
<DetailRow
|
||||||
|
label="الجنسية"
|
||||||
|
value={trip.driver.nationality ?? "—"}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="رخصة القيادة"
|
||||||
|
value={trip.driver.licenseNumber ?? "—"}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="بطاقة السائق"
|
||||||
|
value={trip.driver.driverCardNumber ?? "—"}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="رقم GOSI"
|
||||||
|
value={trip.driver.gosiNumber ?? "—"}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Car */}
|
{/* Car */}
|
||||||
{trip.car && (
|
{trip.car && (
|
||||||
<SectionCard title="بيانات السيارة">
|
<SectionCard title="بيانات السيارة">
|
||||||
<DetailRow label="الشركة" value={trip.car.manufacturer} />
|
<DetailRow label="الشركة" value={trip.car.manufacturer} />
|
||||||
<DetailRow label="الموديل" value={trip.car.model} />
|
<DetailRow label="الموديل" value={trip.car.model} />
|
||||||
<DetailRow label="السنة" value={trip.car.year != null ? String(trip.car.year) : "—"} />
|
<DetailRow
|
||||||
<DetailRow label="اللون" value={trip.car.color ?? "—"} />
|
label="السنة"
|
||||||
<DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono />
|
value={trip.car.year != null ? String(trip.car.year) : "—"}
|
||||||
<DetailRow label="حروف اللوحة" value={trip.car.plateLetters ?? "—"} mono />
|
/>
|
||||||
<DetailRow label="رقم التسجيل" value={trip.car.registrationNumber ?? "—"} mono />
|
<DetailRow label="اللون" value={trip.car.color ?? "—"} />
|
||||||
|
<DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono />
|
||||||
|
<DetailRow
|
||||||
|
label="حروف اللوحة"
|
||||||
|
value={trip.car.plateLetters ?? "—"}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="رقم التسجيل"
|
||||||
|
value={trip.car.registrationNumber ?? "—"}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* System Info */}
|
{/* System Info */}
|
||||||
<SectionCard title="معلومات النظام">
|
<SectionCard title="معلومات النظام">
|
||||||
<DetailRow label="رقم الرحلة" value={trip.tripNumber} mono />
|
<DetailRow label="رقم الرحلة" value={trip.tripNumber} mono />
|
||||||
<DetailRow label="تاريخ الإنشاء" value={fmtDate(trip.createdAt)} />
|
<DetailRow label="تاريخ الإنشاء" value={fmtDate(trip.createdAt)} />
|
||||||
<DetailRow label="آخر تحديث" value={fmtDate(trip.updatedAt)} />
|
<DetailRow label="آخر تحديث" value={fmtDate(trip.updatedAt)} />
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -551,12 +681,14 @@ export default function TripDetailPage() {
|
|||||||
>
|
>
|
||||||
<TripReportPanel tripId={trip.id} />
|
<TripReportPanel tripId={trip.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Edit modal ── */}
|
{/* ── Edit modal ── */}
|
||||||
{editOpen && (
|
{editOpen && (
|
||||||
<TripFormModal
|
<TripFormModal
|
||||||
|
// CHANGE: added key — protects against stale form state if `trip`
|
||||||
|
// is refetched with a new object identity (e.g. after loadTrip()).
|
||||||
|
key={trip.id}
|
||||||
editTrip={trip}
|
editTrip={trip}
|
||||||
onClose={() => setEditOpen(false)}
|
onClose={() => setEditOpen(false)}
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
@@ -574,4 +706,4 @@ export default function TripDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArchivedTripDetailModal } from "@/src/Components/Trip/archive/ArchivedtripDetailModal";
|
import { ArchivedTripDetailModal } from "@/src/Components/Trip/archive/ArchivedtripDetailModal";
|
||||||
// app/dashboard/trips/archived/[tripId]/page.tsx
|
|
||||||
// CHANGE: rebuilt as a thin route wrapper around the shared
|
|
||||||
// ArchivedTripDetailModal instead of its own independent fetch/loading/error/
|
|
||||||
// render logic. This route now shows the exact same full detail view as the
|
|
||||||
// in-app archive browser (driver + car + counts + cash + notes + endReason,
|
|
||||||
// not just the handful of fields this page used to render on its own), and
|
|
||||||
// stays in sync automatically with any future changes to that component.
|
|
||||||
// `onClose` navigates back to the trips list (via router.push) instead of
|
|
||||||
// closing an in-place modal, since this route has no page behind it to
|
|
||||||
// reveal.
|
|
||||||
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,17 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useTrips } from "@/src/hooks/useTrip";
|
import { useTrips } from "@/src/hooks/useTrip";
|
||||||
import { TripFormModal, TripTable } from "@/src/Components/Trip";
|
import { TripFormModal, TripTable } from "@/src/Components/Trip";
|
||||||
import { ArchivedTripModal } from "@/src/Components/Trip/archive/ArchivedTripModal";
|
import { ArchivedTripModal } from "@/src/Components/Trip/archive/ArchivedTripModal";
|
||||||
import { Alert, ArchiveButton, ConfirmDialog, Toast } from "@/src/Components/UI";
|
import {
|
||||||
import type { Trip, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip";
|
Alert,
|
||||||
|
ArchiveButton,
|
||||||
|
ConfirmDialog,
|
||||||
|
Toast,
|
||||||
|
} from "@/src/Components/UI";
|
||||||
|
import type {
|
||||||
|
Trip,
|
||||||
|
CreateTripPayload,
|
||||||
|
UpdateTripPayload,
|
||||||
|
} from "@/src/types/trip";
|
||||||
import Header from "@/src/Components/UI/Header";
|
import Header from "@/src/Components/UI/Header";
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -147,6 +156,9 @@ export default function TripsPage() {
|
|||||||
{/* ── Form modal ── */}
|
{/* ── Form modal ── */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<TripFormModal
|
<TripFormModal
|
||||||
|
// CHANGE: added key — ensures defaultValues reset correctly when
|
||||||
|
// switching between "add new trip" and editing different trips.
|
||||||
|
key={editTrip === null ? "new" : editTrip.id}
|
||||||
editTrip={editTrip}
|
editTrip={editTrip}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={() => setShowForm(false)}
|
||||||
onSubmit={async (payload, isNew) => {
|
onSubmit={async (payload, isNew) => {
|
||||||
@@ -170,7 +182,9 @@ export default function TripsPage() {
|
|||||||
{/* ── Archive browser modal — now the split ArchivedTripModal directly,
|
{/* ── Archive browser modal — now the split ArchivedTripModal directly,
|
||||||
no local wrapper needed since it already ships its own search bar,
|
no local wrapper needed since it already ships its own search bar,
|
||||||
table, and detail-view wiring. ── */}
|
table, and detail-view wiring. ── */}
|
||||||
{archiveOpen && <ArchivedTripModal onClose={() => setArchiveOpen(false)} />}
|
{archiveOpen && (
|
||||||
|
<ArchivedTripModal onClose={() => setArchiveOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Floating archive button — same component Driver page uses ── */}
|
{/* ── Floating archive button — same component Driver page uses ── */}
|
||||||
<ArchiveButton onClick={() => setArchiveOpen(true)} label="الأرشيف" />
|
<ArchiveButton onClick={() => setArchiveOpen(true)} label="الأرشيف" />
|
||||||
@@ -178,4 +192,4 @@ export default function TripsPage() {
|
|||||||
<Toast notification={notification} onDismiss={dismissNotification} />
|
<Toast notification={notification} onDismiss={dismissNotification} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ export default function UsersPage() {
|
|||||||
{/* Create / Edit modal */}
|
{/* Create / Edit modal */}
|
||||||
{formTarget !== false && (
|
{formTarget !== false && (
|
||||||
<UserFormModal
|
<UserFormModal
|
||||||
|
// CHANGE: added key — forces a full remount when switching between
|
||||||
|
// "new" and different edit targets, so useForm's defaultValues
|
||||||
|
// (roleId/branchId included) always reflect the correct user
|
||||||
|
// instead of possibly reusing stale state from a previous instance.
|
||||||
|
key={formTarget === null ? "new" : formTarget.id}
|
||||||
editUser={formTarget}
|
editUser={formTarget}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
branches={branches}
|
branches={branches}
|
||||||
|
|||||||
@@ -21,11 +21,7 @@ export default function ForbiddenPage() {
|
|||||||
border: "2px solid #FECACA",
|
border: "2px solid #FECACA",
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
}}>
|
}}>
|
||||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="1.5">
|
<i className="ti ti-shield-exclamation" style={{ fontSize: 36, color: "#DC2626" }} aria-hidden="true" />
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
||||||
<line x1="12" y1="8" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{
|
<p style={{
|
||||||
|
|||||||
@@ -130,12 +130,7 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
تسجيل الدخول للوحة التحكم
|
تسجيل الدخول للوحة التحكم
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href="/how_work"
|
|
||||||
className="inline-flex h-11 items-center rounded-xl bg-white px-5 text-[13px] font-semibold text-blue-700 shadow-md"
|
|
||||||
>
|
|
||||||
كيف تعمل المنصة؟
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -214,9 +209,7 @@ export default function HomePage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Link href="/features/app" className="text-[13px] font-semibold text-blue-600 hover:text-blue-700">
|
|
||||||
عرض كل المميزات ←
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -273,12 +266,7 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
ابدأ الآن
|
ابدأ الآن
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href="/prices"
|
|
||||||
className="inline-flex h-11 items-center rounded-xl border border-white/30 bg-white/10 px-6 text-[13px] font-semibold text-white backdrop-blur"
|
|
||||||
>
|
|
||||||
عرض الأسعار
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Script from "next/script";
|
||||||
import "@tabler/icons-webfont/dist/tabler-icons.min.css";
|
import "@tabler/icons-webfont/dist/tabler-icons.min.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import ConditionalNavbar from "./components/layout/ConditionalNavbar";
|
import ConditionalNavbar from "./components/layout/ConditionalNavbar";
|
||||||
|
import { ExtensionAttributeCleanup } from "./components/system/ExtensionAttributeCleanup"; // CHANGE: added — fixes hydration mismatch caused by browser extensions
|
||||||
|
|
||||||
import { Footer } from "./components/layout";
|
import { Footer } from "./components/layout";
|
||||||
|
|
||||||
@@ -16,13 +18,38 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
// ⚠️ اتصلح: الـ Navbar والـ footer كانوا برة <html>/<body> وده HTML غير صالح
|
|
||||||
<html lang="ar" dir="rtl">
|
<html lang="ar" dir="rtl">
|
||||||
<body className="app-shell" suppressHydrationWarning>
|
<body className="app-shell" suppressHydrationWarning>
|
||||||
{/* الناف بار بيظهر بس لو مفيش يوزر مسجل دخول */}
|
{/* CHANGE: added — fixes hydration mismatch caused by browser extensions */}
|
||||||
|
<ExtensionAttributeCleanup />
|
||||||
|
{/* CHANGE: navbar is conditional based on route, so we can hide it on login/register pages */}
|
||||||
<ConditionalNavbar />
|
<ConditionalNavbar />
|
||||||
{children}
|
{children}
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<Script
|
||||||
|
id="extension-attribute-pre-hydration-cleanup"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
>
|
||||||
|
{`(() => {
|
||||||
|
const attrs = ["bis_skin_checked", "bis_register", "__processed_by_bitdefender__"];
|
||||||
|
const selector = attrs.map((attr) => "[" + attr + "]").join(",");
|
||||||
|
|
||||||
|
function strip() {
|
||||||
|
document.querySelectorAll(selector).forEach((element) => {
|
||||||
|
attrs.forEach((attr) => element.removeAttribute(attr));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
strip();
|
||||||
|
new MutationObserver(strip).observe(document, {
|
||||||
|
attributes: true,
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributeFilter: attrs,
|
||||||
|
});
|
||||||
|
})();`}
|
||||||
|
</Script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,10 +55,7 @@ export default function NotFound() {
|
|||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
display: "inline-flex", alignItems: "center", gap: 8,
|
display: "inline-flex", alignItems: "center", gap: 8,
|
||||||
}}>
|
}}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<i className="ti ti-home" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
||||||
<polyline points="9 22 9 12 15 12 15 22" />
|
|
||||||
</svg>
|
|
||||||
لوحة التحكم
|
لوحة التحكم
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
120
middleware.ts
Normal file
120
middleware.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// middleware.ts
|
||||||
|
// 1. Runs on the Edge runtime before any page component is rendered.
|
||||||
|
// 2. Handles three guards for the app:
|
||||||
|
// a. Authentication for all /dashboard/* routes — redirects to /login if
|
||||||
|
// no valid auth cookie is present.
|
||||||
|
// b. Role-based access control — blocks the "driver" role from admin routes.
|
||||||
|
// c. Keeps an authenticated user off the public home page — if a logged-in
|
||||||
|
// user manually navigates to "/" or "/home", they are sent back to
|
||||||
|
// /dashboard instead of letting the marketing/home page render.
|
||||||
|
// 3. IMPORTANT: this file must live at the project root (or as src/middleware.ts)
|
||||||
|
// for Next.js to auto-detect and execute it as middleware. It was previously
|
||||||
|
// located at src/middleware/middleware.ts, which Next.js does NOT recognize
|
||||||
|
// as the middleware entrypoint — so none of the guards below were ever
|
||||||
|
// actually running. That misplacement was the root cause of unauthenticated
|
||||||
|
// users being able to reach /dashboard/* pages directly, and of logged-in
|
||||||
|
// users being able to navigate back to "/" or "/home" unchecked.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
// 4. The cookie name must match what the server-side login action sets
|
||||||
|
// (see app/api/auth/set-cookie/route.ts).
|
||||||
|
const AUTH_COOKIE_NAME = "auth_token";
|
||||||
|
|
||||||
|
// 5. Route groups this middleware cares about.
|
||||||
|
const PROTECTED_PREFIX = "/dashboard";
|
||||||
|
const PUBLIC_HOME_PATHS = ["/", "/home"];
|
||||||
|
|
||||||
|
// 6. Lightweight JWT payload decoder.
|
||||||
|
// We only need the `role` claim — we do NOT verify the signature here
|
||||||
|
// because the backend already validates the token on every API request.
|
||||||
|
// Signature verification in middleware would require the secret to be
|
||||||
|
// bundled into the Edge runtime, which is its own security concern.
|
||||||
|
// The authoritative check is always the backend; middleware is a UX guard.
|
||||||
|
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
// Base64url -> Base64 -> JSON
|
||||||
|
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
// atob is available in the Edge runtime
|
||||||
|
const json = atob(base64);
|
||||||
|
return JSON.parse(json) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
const authCookie = request.cookies.get(AUTH_COOKIE_NAME);
|
||||||
|
const isAuthenticated = !!authCookie?.value && !!decodeJwtPayload(authCookie.value);
|
||||||
|
|
||||||
|
// 7. Guard 1: public home ("/" and "/home"). An authenticated user must not
|
||||||
|
// be able to land back on the marketing/home page by manually editing the
|
||||||
|
// URL — bounce them to the dashboard instead. An unauthenticated visitor
|
||||||
|
// passes through normally.
|
||||||
|
if (PUBLIC_HOME_PATHS.includes(pathname)) {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", request.url));
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Guard 2: only the remaining checks apply to dashboard routes.
|
||||||
|
// (The `matcher` config below already limits execution, but this is an
|
||||||
|
// explicit check for clarity and defensive depth.)
|
||||||
|
if (!pathname.startsWith(PROTECTED_PREFIX)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Guard 3: authentication. No cookie at all -> redirect to /login,
|
||||||
|
// preserving the original URL so we can send the user back after
|
||||||
|
// a successful login.
|
||||||
|
if (!authCookie?.value) {
|
||||||
|
const loginUrl = new URL("/login", request.url);
|
||||||
|
loginUrl.searchParams.set("next", pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Decode the JWT payload (not verified — see decodeJwtPayload above).
|
||||||
|
const payload = decodeJwtPayload(authCookie.value);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
// 11. Malformed or expired token — treat exactly like "not authenticated".
|
||||||
|
const loginUrl = new URL("/login", request.url);
|
||||||
|
loginUrl.searchParams.set("next", pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Guard 4: role-based access control. The role field matches what
|
||||||
|
// lib/auth.ts stores: a plain string like "driver", "admin", "user"
|
||||||
|
// (extracted from the backend JWT during login).
|
||||||
|
const role =
|
||||||
|
typeof payload.role === "string" ? payload.role.toLowerCase() : null;
|
||||||
|
|
||||||
|
const BLOCKED_ROLES = ["driver", "سائق"];
|
||||||
|
|
||||||
|
if (role && BLOCKED_ROLES.includes(role)) {
|
||||||
|
// 13. Driver accounts must never access the admin dashboard.
|
||||||
|
return NextResponse.redirect(new URL("/forbidden", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. All checks passed — allow the request through.
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// 15. Apply this middleware to the public home paths (so authenticated
|
||||||
|
// users get bounced off them) and to the dashboard root plus every
|
||||||
|
// sub-route (so unauthenticated visitors and blocked roles get
|
||||||
|
// redirected away from admin pages).
|
||||||
|
matcher: [
|
||||||
|
"/",
|
||||||
|
"/home",
|
||||||
|
"/dashboard",
|
||||||
|
"/dashboard/:path*",
|
||||||
|
],
|
||||||
|
};
|
||||||
0
src/Components/Admen/admen.tsx
Normal file
0
src/Components/Admen/admen.tsx
Normal file
@@ -39,10 +39,7 @@ function BranchIcon() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
boxShadow: "0 4px 12px rgba(37,99,235,.3)",
|
boxShadow: "0 4px 12px rgba(37,99,235,.3)",
|
||||||
}}>
|
}}>
|
||||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#FFF" strokeWidth="2">
|
<i className="ti ti-building" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
|
||||||
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
|
|
||||||
<path d="M9 9v.01M9 12v.01M9 15v.01M9 18v.01" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,144 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Button, Input, Modal } from "../UI";
|
import { Alert, Button, Input, Modal, Select } from "../UI";
|
||||||
import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator";
|
import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator";
|
||||||
|
import {
|
||||||
|
getRegions,
|
||||||
|
getCitiesByRegion,
|
||||||
|
getDistrictsByCity,
|
||||||
|
resolveLocation,
|
||||||
|
findLocationByNames,
|
||||||
|
} from "@/src/lib/locationHierarchy";
|
||||||
import type { Branch, BranchFormData } from "@/src/types/branch";
|
import type { Branch, BranchFormData } from "@/src/types/branch";
|
||||||
|
|
||||||
const FORM_ID = "branch-form";
|
const FORM_ID = "branch-form";
|
||||||
|
|
||||||
|
// Step 1: Extend the plain form-values shape with the three hierarchy ids.
|
||||||
|
// BranchFormData itself stays untouched (see src/types/branch.ts) — city/
|
||||||
|
// state/district there are still plain strings, since that's the API
|
||||||
|
// contract. This local type is what react-hook-form actually manages.
|
||||||
|
interface BranchFormValues extends BranchFormData {
|
||||||
|
regionId: string;
|
||||||
|
cityId: string;
|
||||||
|
districtId: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
interface BranchFormModalProps {
|
interface BranchFormModalProps {
|
||||||
editBranch: Branch | null;
|
editBranch: Branch | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (data: BranchFormData, isNew: boolean) => Promise<boolean>;
|
onSubmit: (data: BranchFormData, isNew: boolean) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── main component ────────────────────────────────────────────────────────────
|
// ── main component ────────────────────────────────────────────────────────────
|
||||||
export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) {
|
export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) {
|
||||||
const isNew = editBranch === null;
|
const isNew = editBranch === null;
|
||||||
|
|
||||||
|
// Step 2: On edit, try to resolve the branch's stored city/state/district
|
||||||
|
// NAMES back into ids against the current dataset. If nothing matches
|
||||||
|
// (the value is a legacy/unknown location no longer in the hierarchy),
|
||||||
|
// resolved stays null and the form falls back to empty selects — the
|
||||||
|
// person must actively re-pick a valid Region -> City -> District chain
|
||||||
|
// before they can save, rather than silently keeping a stale value.
|
||||||
|
const resolvedEdit = useMemo(
|
||||||
|
() => (editBranch ? findLocationByNames(editBranch.state, editBranch.city, editBranch.district) : null),
|
||||||
|
[editBranch],
|
||||||
|
);
|
||||||
|
const isLegacyLocation = !isNew && !resolvedEdit && !!(editBranch?.city || editBranch?.district);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setError,
|
setError,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
control,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<BranchFormData>({
|
} = useForm<BranchFormValues>({
|
||||||
// Cast the schema itself and pin the generic explicitly on yupResolver —
|
resolver: yupResolver<BranchFormValues>((isNew ? createBranchSchema : updateBranchSchema) as any),
|
||||||
// create/update schemas differ structurally in which fields are required
|
|
||||||
// (e.g. city/street are optional on update), so yup can't unify them
|
|
||||||
// into the single flat BranchFormData shape on its own.
|
|
||||||
resolver: yupResolver<BranchFormData>((isNew ? createBranchSchema : updateBranchSchema) as any),
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: editBranch?.name ?? "",
|
name: editBranch?.name ?? "",
|
||||||
email: editBranch?.email ?? "",
|
email: editBranch?.email ?? "",
|
||||||
phone: editBranch?.phone ?? "",
|
phone: editBranch?.phone ?? "",
|
||||||
country: editBranch?.country ?? "SA",
|
country: editBranch?.country ?? "SA",
|
||||||
city: editBranch?.city ?? "",
|
regionId: resolvedEdit?.regionId ?? "",
|
||||||
state: editBranch?.state ?? "",
|
cityId: resolvedEdit?.cityId ?? "",
|
||||||
district: editBranch?.district ?? "",
|
districtId: resolvedEdit?.districtId ?? "",
|
||||||
street: editBranch?.street ?? "",
|
city: editBranch?.city ?? "",
|
||||||
|
state: editBranch?.state ?? "",
|
||||||
|
district: editBranch?.district ?? "",
|
||||||
|
street: editBranch?.street ?? "",
|
||||||
buildingNo: editBranch?.buildingNo ?? "",
|
buildingNo: editBranch?.buildingNo ?? "",
|
||||||
unitNo: editBranch?.unitNo ?? "",
|
unitNo: editBranch?.unitNo ?? "",
|
||||||
zipCode: editBranch?.zipCode ?? "",
|
zipCode: editBranch?.zipCode ?? "",
|
||||||
latitude: editBranch?.latitude != null ? String(editBranch.latitude) : "",
|
latitude: resolvedEdit?.latitude != null ? String(resolvedEdit.latitude) : editBranch?.latitude != null ? String(editBranch.latitude) : "",
|
||||||
longitude: editBranch?.longitude != null ? String(editBranch.longitude) : "",
|
longitude: resolvedEdit?.longitude != null ? String(resolvedEdit.longitude) : editBranch?.longitude != null ? String(editBranch.longitude) : "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const submitHandler = async (data: BranchFormData) => {
|
// Step 3: Watch the two parent selects so the child dropdowns can filter
|
||||||
const ok = await onSubmit(data, isNew);
|
// themselves reactively (cascading Region -> City -> District).
|
||||||
|
const watchedRegionId = watch("regionId");
|
||||||
|
const watchedCityId = watch("cityId");
|
||||||
|
const watchedDistrictId = watch("districtId");
|
||||||
|
|
||||||
|
const regions = useMemo(() => getRegions(), []);
|
||||||
|
const cities = useMemo(() => getCitiesByRegion(watchedRegionId), [watchedRegionId]);
|
||||||
|
const districts = useMemo(
|
||||||
|
() => getDistrictsByCity(watchedRegionId, watchedCityId),
|
||||||
|
[watchedRegionId, watchedCityId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 4: Whenever the region changes, clear any city/district selection
|
||||||
|
// that no longer belongs to it — prevents the exact "Mohandessin under
|
||||||
|
// Cairo" class of bug from ever reaching submit.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!watchedCityId) return;
|
||||||
|
const stillValid = cities.some((c) => c.id === watchedCityId);
|
||||||
|
if (!stillValid) {
|
||||||
|
setValue("cityId", "");
|
||||||
|
setValue("districtId", "");
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [watchedRegionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!watchedDistrictId) return;
|
||||||
|
const stillValid = districts.some((d) => d.id === watchedDistrictId);
|
||||||
|
if (!stillValid) setValue("districtId", "");
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [watchedCityId]);
|
||||||
|
|
||||||
|
// Step 5: Whenever the fully-resolved district changes, auto-populate
|
||||||
|
// latitude/longitude from the dataset — no manual coordinate entry.
|
||||||
|
useEffect(() => {
|
||||||
|
const resolved = resolveLocation(watchedRegionId, watchedCityId, watchedDistrictId);
|
||||||
|
if (resolved) {
|
||||||
|
setValue("latitude", String(resolved.latitude));
|
||||||
|
setValue("longitude", String(resolved.longitude));
|
||||||
|
}
|
||||||
|
}, [watchedRegionId, watchedCityId, watchedDistrictId, setValue]);
|
||||||
|
|
||||||
|
const submitHandler = async (data: BranchFormValues) => {
|
||||||
|
// Step 6: Resolve the chosen ids into display names right before
|
||||||
|
// building the payload, so the API keeps receiving plain strings for
|
||||||
|
// city/state/district exactly as it does today — this UI change is
|
||||||
|
// fully transparent to the backend contract.
|
||||||
|
const resolved = resolveLocation(data.regionId, data.cityId, data.districtId);
|
||||||
|
|
||||||
|
const payload: BranchFormData = {
|
||||||
|
...data,
|
||||||
|
state: resolved?.regionName ?? data.state,
|
||||||
|
city: resolved?.cityName ?? data.city,
|
||||||
|
district: resolved?.districtName ?? data.district,
|
||||||
|
latitude: resolved ? String(resolved.latitude) : data.latitude,
|
||||||
|
longitude: resolved ? String(resolved.longitude) : data.longitude,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ok = await onSubmit(payload, isNew);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
@@ -84,6 +174,17 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Step 7: Warn once if the stored location no longer matches any
|
||||||
|
valid Region -> City -> District chain, and require a fresh
|
||||||
|
selection instead of silently keeping the legacy value. */}
|
||||||
|
{isLegacyLocation && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message="الموقع المحفوظ لهذا الفرع لم يعد مطابقًا لأي منطقة/مدينة/حي معروف. الرجاء اختيار الموقع الصحيح من القوائم أدناه."
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* name */}
|
{/* name */}
|
||||||
<Input
|
<Input
|
||||||
label="اسم الفرع *"
|
label="اسم الفرع *"
|
||||||
@@ -117,15 +218,73 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* city + street */}
|
{/* Step 8: Cascading Region -> City -> District selects. Each
|
||||||
|
child <Select> is disabled until its parent has a value, and
|
||||||
|
its options come straight from the resolved parent id — a
|
||||||
|
district literally cannot be picked unless it belongs to the
|
||||||
|
currently-selected city. */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<Input
|
<Controller
|
||||||
label="المدينة *"
|
name="regionId"
|
||||||
{...register("city")}
|
control={control}
|
||||||
error={errors.city?.message}
|
render={({ field }) => (
|
||||||
placeholder="الرياض"
|
<Select
|
||||||
dir="rtl"
|
label="المنطقة *"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={(errors as any).regionId?.message}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="">اختر المنطقة</option>
|
||||||
|
{regions.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>{r.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="cityId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
label="المدينة *"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={(errors as any).cityId?.message}
|
||||||
|
disabled={!watchedRegionId}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="">{watchedRegionId ? "اختر المدينة" : "اختر المنطقة أولاً"}</option>
|
||||||
|
{cities.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<Controller
|
||||||
|
name="districtId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
label="الحي *"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={(errors as any).districtId?.message}
|
||||||
|
disabled={!watchedCityId}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="">{watchedCityId ? "اختر الحي" : "اختر المدينة أولاً"}</option>
|
||||||
|
{districts.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>{d.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="الشارع *"
|
label="الشارع *"
|
||||||
{...register("street")}
|
{...register("street")}
|
||||||
@@ -135,24 +294,6 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* state + district */}
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<Input
|
|
||||||
label="المنطقة"
|
|
||||||
{...register("state")}
|
|
||||||
error={errors.state?.message}
|
|
||||||
placeholder="منطقة الرياض"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="الحي"
|
|
||||||
{...register("district")}
|
|
||||||
error={errors.district?.message}
|
|
||||||
placeholder="حي العليا"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* buildingNo + unitNo + zipCode */}
|
{/* buildingNo + unitNo + zipCode */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
<Input
|
<Input
|
||||||
@@ -185,25 +326,33 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
error={errors.country?.message}
|
error={errors.country?.message}
|
||||||
placeholder="SA"
|
placeholder="SA"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* latitude + longitude */}
|
{/* Step 9: latitude/longitude are now READ-ONLY — they are derived
|
||||||
|
automatically from the selected district (Step 5 above), so
|
||||||
|
there is no manual entry and therefore no coordinate-vs-district
|
||||||
|
mismatch possible from this form. */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<Input
|
<Input
|
||||||
label="خط العرض (اختياري)"
|
label="خط العرض (تلقائي)"
|
||||||
{...register("latitude")}
|
{...register("latitude")}
|
||||||
error={errors.latitude?.message}
|
error={errors.latitude?.message}
|
||||||
placeholder="24.7136"
|
placeholder="—"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="خط الطول (اختياري)"
|
label="خط الطول (تلقائي)"
|
||||||
{...register("longitude")}
|
{...register("longitude")}
|
||||||
error={errors.longitude?.message}
|
error={errors.longitude?.message}
|
||||||
placeholder="46.6753"
|
placeholder="—"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,134 +1,96 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../UI";
|
// src/Components/Branch/BranchTable.tsx
|
||||||
|
// MIGRATED to ReusableTable + ActionButtons — same props, same visual
|
||||||
|
// result as the original hand-rolled version. No changes required in
|
||||||
|
// app/dashboard/branches/page.tsx.
|
||||||
|
|
||||||
|
import { Badge, Button, ReusableTable, ActionButtons } from "../UI";
|
||||||
|
import type { TableColumn } from "@/src/types/models";
|
||||||
import type { Branch } from "@/src/types/branch";
|
import type { Branch } from "@/src/types/branch";
|
||||||
|
|
||||||
// ── card / header styles ─────────────────────────────────────────────────────
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Props ────────────────────────────────────────────────────────────────────
|
|
||||||
interface BranchTableProps {
|
interface BranchTableProps {
|
||||||
branches: Branch[];
|
branches: Branch[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
search: string;
|
search: string;
|
||||||
page: number;
|
page: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
onEdit: (branch: Branch) => void;
|
onEdit: (branch: Branch) => void;
|
||||||
onDelete: (branch: Branch) => void;
|
onDelete: (branch: Branch) => void;
|
||||||
onView: (branch: Branch) => void;
|
onView: (branch: Branch) => void;
|
||||||
onAddFirst: () => void;
|
onAddFirst: () => void;
|
||||||
onPageChange: (p: number) => void;
|
onPageChange: (p: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── main table ───────────────────────────────────────────────────────────────
|
export function BranchTable({
|
||||||
export function BranchTable({ branches, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: BranchTableProps) {
|
branches, loading, search, page, pages,
|
||||||
|
onEdit, onDelete, onView, onAddFirst, onPageChange,
|
||||||
|
}: BranchTableProps) {
|
||||||
|
const columns: TableColumn<Branch>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "اسم الفرع",
|
||||||
|
width: "2fr",
|
||||||
|
render: (b) => (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
|
||||||
|
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "city", header: "المدينة", width: "1.5fr", render: (b) => b.city || "—" },
|
||||||
|
{
|
||||||
|
key: "phone",
|
||||||
|
header: "رقم الجوال",
|
||||||
|
width: "1.5fr",
|
||||||
|
render: (b) => (
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||||
|
{b.phone ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "الحالة",
|
||||||
|
width: "1fr",
|
||||||
|
render: (b) => <Badge label={b.isActive ? "Active" : "Disabled"} color={b.isActive ? "green" : "red"} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
header: "تاريخ الانشاء",
|
||||||
|
width: "1fr",
|
||||||
|
align: "center",
|
||||||
|
render: (b) =>
|
||||||
|
new Date(b.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardStyle}>
|
<ReusableTable
|
||||||
{/* column headers */}
|
columns={columns}
|
||||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 100px", ...thStyle }}>
|
data={branches}
|
||||||
<span>الفرع</span>
|
loading={loading}
|
||||||
<span>المدينة</span>
|
search={search}
|
||||||
<span>الهاتف</span>
|
page={page}
|
||||||
<span>الحالة</span>
|
pages={pages}
|
||||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
onPageChange={onPageChange}
|
||||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
emptyIcon="🏢"
|
||||||
</div>
|
emptyDescription={!search ? "No branches to display." : undefined}
|
||||||
|
emptyAction={
|
||||||
{/* loading state */}
|
!search && (
|
||||||
{loading ? (
|
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
Add first branch
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
</Button>
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
)
|
||||||
</div>
|
}
|
||||||
) : branches.length === 0 ? (
|
renderActions={(b) => (
|
||||||
/* empty state */
|
<ActionButtons
|
||||||
<EmptyState
|
itemLabel={b.name}
|
||||||
icon="🏢"
|
onView={() => onView(b)}
|
||||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."}
|
onEdit={() => onEdit(b)}
|
||||||
action={
|
onDelete={() => onDelete(b)}
|
||||||
!search && (
|
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
|
||||||
أضف أول فرع
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
/* data rows */
|
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
|
||||||
{branches.map((b, i) => (
|
|
||||||
<li key={b.id} style={{
|
|
||||||
display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 100px",
|
|
||||||
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
|
||||||
fontSize: 13,
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
|
|
||||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
|
|
||||||
</div>
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>{b.city || "—"}</span>
|
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-secondary)" }}>{b.phone ?? "—"}</span>
|
|
||||||
<Badge label={b.isActive ? "نشط" : "معطل"} color={b.isActive ? "green" : "red"} />
|
|
||||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
|
||||||
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
|
||||||
{/* view */}
|
|
||||||
<IconBtn title={`عرض ${b.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(b)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
{/* edit */}
|
|
||||||
<IconBtn title={`تعديل ${b.name}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(b)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
{/* delete */}
|
|
||||||
<IconBtn title={`حذف ${b.name}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(b)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
{/* pagination */}
|
|
||||||
{pages > 1 && (
|
|
||||||
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
{[
|
|
||||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
|
||||||
].map(btn => (
|
|
||||||
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}>
|
|
||||||
{btn.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
58
src/Components/Branch/DeleteConfirmModal.tsx
Normal file
58
src/Components/Branch/DeleteConfirmModal.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import type { Branch } from "@/src/types/branch";
|
||||||
|
|
||||||
|
interface DeleteConfirmModalProps {
|
||||||
|
branch: Branch;
|
||||||
|
deleting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirmModal({ branch, deleting, onCancel, onConfirm }: DeleteConfirmModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alertdialog" aria-modal="true" aria-labelledby="branch-del-title"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
|
style={{ position: "fixed", inset: 0, zIndex: 60, background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{ width: "100%", maxWidth: 400, background: "var(--color-surface)", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", boxShadow: "0 20px 48px rgba(0,0,0,.18)", padding: "2rem", display: "flex", flexDirection: "column", gap: "1rem", textAlign: "center" }}
|
||||||
|
>
|
||||||
|
{/* icon delete */}
|
||||||
|
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 id="branch-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>حذف الفرع</h2>
|
||||||
|
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
||||||
|
هل أنت متأكد من حذف فرع <strong style={{ color: "var(--color-text-primary)" }}>{branch.name}</strong>؟ لا يمكن التراجع عن هذا الإجراء.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button type="button" onClick={onCancel} disabled={deleting}
|
||||||
|
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onConfirm} disabled={deleting}
|
||||||
|
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
|
||||||
|
{deleting && <Spinner size="sm" className="text-white" />}
|
||||||
|
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,10 +35,7 @@ function BranchIcon() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
boxShadow: "0 4px 12px rgba(234,88,12,.3)",
|
boxShadow: "0 4px 12px rgba(234,88,12,.3)",
|
||||||
}}>
|
}}>
|
||||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#FFF" strokeWidth="2">
|
<i className="ti ti-building" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
|
||||||
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
|
|
||||||
<path d="M9 9v.01M9 12v.01M9 15v.01M9 18v.01" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,121 +1,70 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge, Button, IconBtn, Spinner } from "../../UI";
|
// src/Components/Branch/archive/ArchivedBranchTable.tsx
|
||||||
|
// MIGRATED to ReusableTable + ActionButtons. View-only, no row click in
|
||||||
|
// the original.
|
||||||
|
|
||||||
|
import { Badge, ReusableTable, ActionButtons } from "../../UI";
|
||||||
|
import type { TableColumn } from "@/src/types/models";
|
||||||
import type { ArchivedBranch } from "@/src/types/branch";
|
import type { ArchivedBranch } from "@/src/types/branch";
|
||||||
|
|
||||||
// ── card / header styles ─────────────────────────────────────────────────────
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Props ────────────────────────────────────────────────────────────────────
|
|
||||||
interface ArchivedBranchTableProps {
|
interface ArchivedBranchTableProps {
|
||||||
branches: ArchivedBranch[];
|
branches: ArchivedBranch[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
search: string;
|
search: string;
|
||||||
page: number;
|
page: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
onView: (branch: ArchivedBranch) => void;
|
onView: (branch: ArchivedBranch) => void;
|
||||||
onPageChange: (p: number) => void;
|
onPageChange: (p: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── main table ───────────────────────────────────────────────────────────────
|
|
||||||
export function ArchivedBranchTable({ branches, loading, search, page, pages, onView, onPageChange }: ArchivedBranchTableProps) {
|
export function ArchivedBranchTable({ branches, loading, search, page, pages, onView, onPageChange }: ArchivedBranchTableProps) {
|
||||||
|
const columns: TableColumn<ArchivedBranch>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "اسم الفرع",
|
||||||
|
width: "2fr",
|
||||||
|
render: (b) => (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
|
||||||
|
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "city", header: "المدينة", width: "1.5fr", render: (b) => b.city || "—" },
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "الحالة",
|
||||||
|
width: "1fr",
|
||||||
|
render: (b) => <Badge label={b.isActive ? "Active" : "Disabled"} color={b.isActive ? "green" : "red"} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
header: "تاريخ الانشاء",
|
||||||
|
width: "1fr",
|
||||||
|
align: "center",
|
||||||
|
render: (b) => new Date(b.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
header: "تاريخ التعديل",
|
||||||
|
width: "1fr",
|
||||||
|
align: "center",
|
||||||
|
render: (b) => new Date(b.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardStyle}>
|
<ReusableTable
|
||||||
{/* column headers */}
|
columns={columns}
|
||||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
|
data={branches}
|
||||||
<span>الفرع</span>
|
loading={loading}
|
||||||
<span>المدينة</span>
|
search={search}
|
||||||
<span>الحالة</span>
|
page={page}
|
||||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
pages={pages}
|
||||||
<span style={{ textAlign: "center" }}>آخر تحديث</span>
|
onPageChange={onPageChange}
|
||||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
emptyDescription={search ? `No results for "${search}"` : "No archived branches."}
|
||||||
</div>
|
renderActions={(b) => <ActionButtons itemLabel={b.name} onView={() => onView(b)} />}
|
||||||
|
/>
|
||||||
{/* loading state */}
|
|
||||||
{loading ? (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
|
||||||
</div>
|
|
||||||
) : branches.length === 0 ? (
|
|
||||||
/* empty state */
|
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
|
||||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع في الأرشيف."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* data rows */
|
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
|
||||||
{branches.map((b, i) => (
|
|
||||||
<li key={b.id} style={{
|
|
||||||
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px",
|
|
||||||
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
|
||||||
fontSize: 13,
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
|
|
||||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
|
|
||||||
</div>
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>{b.city || "—"}</span>
|
|
||||||
<Badge label={b.isActive ? "نشط" : "معطل"} color={b.isActive ? "green" : "red"} />
|
|
||||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
|
||||||
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
|
||||||
</span>
|
|
||||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
|
||||||
{new Date(b.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
|
||||||
{/* view only — delete/restore not implemented yet */}
|
|
||||||
<IconBtn title={`عرض ${b.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(b)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* pagination */}
|
|
||||||
{pages > 1 && (
|
|
||||||
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
>
|
|
||||||
السابق
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
|
||||||
disabled={page === pages}
|
|
||||||
>
|
|
||||||
التالي
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,9 +12,7 @@ interface ArchivedBranchesModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SearchIcon = (
|
const SearchIcon = (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {
|
export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {
|
||||||
|
|||||||
91
src/Components/Car_Maintanance/CarMaintananceDeleteModal.tsx
Normal file
91
src/Components/Car_Maintanance/CarMaintananceDeleteModal.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import { fmtCost } from "@/src/types/carMaintanance";
|
||||||
|
import type { CarMaintenance } from "@/src/types/carMaintanance";
|
||||||
|
|
||||||
|
interface CarMaintenanceDeleteModalProps {
|
||||||
|
record: CarMaintenance;
|
||||||
|
deleting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CarMaintenanceDeleteModal({
|
||||||
|
record,
|
||||||
|
deleting,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: CarMaintenanceDeleteModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alertdialog" aria-modal="true" aria-labelledby="maintenance-del-title"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 60,
|
||||||
|
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%", maxWidth: 420,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
||||||
|
padding: "2rem",
|
||||||
|
display: "flex", flexDirection: "column", gap: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 id="maintenance-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
|
حذف سجل الصيانة
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
||||||
|
هل أنت متأكد من حذف سجل{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{record.reason}
|
||||||
|
</strong>
|
||||||
|
{" "}(
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||||
|
{fmtCost(record.cost)}
|
||||||
|
</span>
|
||||||
|
)؟ لا يمكن التراجع عن هذا الإجراء.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button type="button" onClick={onCancel} disabled={deleting}
|
||||||
|
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onConfirm} disabled={deleting}
|
||||||
|
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
|
||||||
|
{deleting && <Spinner size="sm" className="text-white" />}
|
||||||
|
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
// src/Components/Car_Maintanance/CarMaintananceDetailPanel.tsx
|
|
||||||
// CHANGE: replaced CarMaintenanceDeleteModal with ConfirmDialog per Issue 1.
|
|
||||||
// Description string rebuilt inline using fmtCost, matching the original
|
|
||||||
// modal's wording exactly (see Issue 1 edge-case note on preserving fmtCost formatting).
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
@@ -252,9 +248,7 @@ export function CarMaintenanceDetailPanel({ carId, carLabel, onClose, onCarStatu
|
|||||||
<div style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", flexShrink: 0 }}>
|
<div style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", flexShrink: 0 }}>
|
||||||
<button type="button" onClick={() => setFormTarget(null)}
|
<button type="button" onClick={() => setFormTarget(null)}
|
||||||
style={{ width: "100%", height: 40, borderRadius: "var(--radius-md)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
|
style={{ width: "100%", height: 40, borderRadius: "var(--radius-md)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
إضافة سجل صيانة
|
إضافة سجل صيانة
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
|
||||||
import { fmtDateShort, fmtCost } from "@/src/types/carMaintanance";
|
|
||||||
import type { CarMaintenance } from "@/src/types/carMaintanance";
|
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
import { IconBtn, ReusableTable } from "../../UI";
|
||||||
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
import { fmtDateShort, fmtCost } from "@/src/types/carMaintanance";
|
||||||
};
|
import type { TableColumn } from "@/src/types/models";
|
||||||
const thStyle: React.CSSProperties = {
|
import type { CarMaintenance } from "@/src/types/carMaintanance";
|
||||||
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ArchivedCarMaintenanceTableProps {
|
interface ArchivedCarMaintenanceTableProps {
|
||||||
records: CarMaintenance[];
|
records: CarMaintenance[];
|
||||||
@@ -30,85 +22,78 @@ interface ArchivedCarMaintenanceTableProps {
|
|||||||
export function ArchivedCarMaintenanceTable({
|
export function ArchivedCarMaintenanceTable({
|
||||||
records, loading, search, page, pages, showCar = false, onView, onPageChange,
|
records, loading, search, page, pages, showCar = false, onView, onPageChange,
|
||||||
}: ArchivedCarMaintenanceTableProps) {
|
}: ArchivedCarMaintenanceTableProps) {
|
||||||
const columns = showCar ? "2fr 1fr 1fr 1fr 1fr 100px" : "2fr 1fr 1fr 1fr 100px";
|
const columns: TableColumn<CarMaintenance>[] = [
|
||||||
|
{
|
||||||
|
key: "reason",
|
||||||
|
header: "سبب الصيانة",
|
||||||
|
width: "2fr",
|
||||||
|
render: (r) => (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{r.reason}</p>
|
||||||
|
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>أضيف {fmtDateShort(r.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Only shown in the global (cross-car) archive view.
|
||||||
|
...(showCar
|
||||||
|
? [{
|
||||||
|
key: "car",
|
||||||
|
header: "المركبة",
|
||||||
|
width: "1fr",
|
||||||
|
render: (r: CarMaintenance) => (
|
||||||
|
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||||
|
{r.car ? `${r.car.manufacturer} ${r.car.model}` : "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
} as TableColumn<CarMaintenance>]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
key: "cost",
|
||||||
|
header: "التكلفة",
|
||||||
|
width: "1fr",
|
||||||
|
render: (r) => (
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
|
||||||
|
{fmtCost(r.cost)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "startAt", header: "تاريخ البدء", width: "1fr", render: (r) => (
|
||||||
|
<span style={{ color: "var(--color-text-secondary)" }}>{fmtDateShort(r.startAt)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "endAt",
|
||||||
|
header: "تاريخ الانتهاء",
|
||||||
|
width: "1fr",
|
||||||
|
render: (r) => (
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||||
|
{fmtDateShort(r.endAt)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardStyle}>
|
<ReusableTable
|
||||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: columns, ...thStyle }}>
|
columns={columns}
|
||||||
<span>سبب الصيانة</span>
|
data={records}
|
||||||
{showCar && <span>المركبة</span>}
|
loading={loading}
|
||||||
<span>التكلفة</span>
|
search={search}
|
||||||
<span>تاريخ البدء</span>
|
page={page}
|
||||||
<span style={{ textAlign: "center" }}>تاريخ الانتهاء</span>
|
pages={pages}
|
||||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
onPageChange={onPageChange}
|
||||||
</div>
|
actionsWidth="100px"
|
||||||
|
onRowClick={onView}
|
||||||
{loading ? (
|
emptyDescription={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."}
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
renderActions={(r) => (
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
<IconBtn
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
title={`عرض ${r.reason}`}
|
||||||
</div>
|
color="#059669" bg="#ECFDF5" borderColor="#A7F3D0"
|
||||||
) : records.length === 0 ? (
|
onClick={() => onView(r)}
|
||||||
<EmptyState
|
>
|
||||||
icon="🔧"
|
<i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."}
|
</IconBtn>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
|
||||||
{records.map((r, i) => (
|
|
||||||
<li key={r.id} style={{
|
|
||||||
display: "grid", gridTemplateColumns: columns,
|
|
||||||
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
|
||||||
fontSize: 13,
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{r.reason}</p>
|
|
||||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>أضيف {fmtDateShort(r.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
{showCar && (
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
|
||||||
{r.car ? `${r.car.manufacturer} ${r.car.model}` : "—"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
|
|
||||||
{fmtCost(r.cost)}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>{fmtDateShort(r.startAt)}</span>
|
|
||||||
<span style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
{fmtDateShort(r.endAt)}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
|
||||||
<IconBtn title={`عرض ${r.reason}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(r)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
{pages > 1 && (
|
|
||||||
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
{[
|
|
||||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
|
||||||
].map(btn => (
|
|
||||||
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}>
|
|
||||||
{btn.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,9 +51,7 @@ export function ArchivedCarsMaintenanceModal({ carId, carLabel, onClose }: Archi
|
|||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
icon={
|
icon={
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
392
src/Components/Client/Addressformmodal.tsx
Normal file
392
src/Components/Client/Addressformmodal.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Alert, Spinner } from "../UI";
|
||||||
|
import type {
|
||||||
|
ClientAddress,
|
||||||
|
ClientAddressFormData,
|
||||||
|
ClientAddressFormErrors,
|
||||||
|
} from "@/src/types/client";
|
||||||
|
|
||||||
|
// ── Fixed styles ───────────────────────────────────────────────────────────
|
||||||
|
const S = {
|
||||||
|
input: {
|
||||||
|
width: "100%",
|
||||||
|
height: 40,
|
||||||
|
padding: "0 0.75rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
} as React.CSSProperties,
|
||||||
|
label: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column" as const,
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
} as React.CSSProperties,
|
||||||
|
errorText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-danger)",
|
||||||
|
fontWeight: 500,
|
||||||
|
} as React.CSSProperties,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Validation ─────────────────────────────────────────────────────────────
|
||||||
|
function validate(data: ClientAddressFormData): ClientAddressFormErrors {
|
||||||
|
const e: ClientAddressFormErrors = {};
|
||||||
|
if (!data.label.trim()) e.label = "النوع مطلوب";
|
||||||
|
if (!data.street.trim()) e.street = "العنوان مطلوب";
|
||||||
|
if (!data.city.trim()) e.city = "المدينة مطلوبة";
|
||||||
|
if (!data.state.trim()) e.state = "المنطقة مطلوبة";
|
||||||
|
if (!data.postalCode.trim()) e.postalCode = "الرمز البريدي مطلوب";
|
||||||
|
if (!data.country.trim()) e.country = "الدولة مطلوبة";
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_PRESETS = ["فوترة", "شحن", "المقر الرئيسي", "فرع", "مستودع", "أخرى"];
|
||||||
|
|
||||||
|
// ── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
interface AddressFormModalProps {
|
||||||
|
editAddress: ClientAddress | null; // null = create mode
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: ClientAddressFormData, isNew: boolean) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
export function AddressFormModal({
|
||||||
|
editAddress,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: AddressFormModalProps) {
|
||||||
|
const isNew = editAddress === null;
|
||||||
|
|
||||||
|
const [form, setForm] = useState<ClientAddressFormData>({
|
||||||
|
label: editAddress?.label ?? "",
|
||||||
|
street: editAddress?.street ?? "",
|
||||||
|
city: editAddress?.city ?? "",
|
||||||
|
state: editAddress?.state ?? "",
|
||||||
|
postalCode: editAddress?.postalCode ?? "",
|
||||||
|
country: editAddress?.country ?? "المملكة العربية السعودية",
|
||||||
|
isPrimary: editAddress?.isPrimary ?? false,
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<ClientAddressFormErrors>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [apiError, setApiError] = useState("");
|
||||||
|
const firstRef = useRef<HTMLSelectElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => { firstRef.current?.focus(); }, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const set = (field: keyof ClientAddressFormData) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
setForm((p) => ({ ...p, [field]: e.target.value }));
|
||||||
|
if (errors[field as keyof ClientAddressFormErrors])
|
||||||
|
setErrors((p) => ({ ...p, [field]: undefined }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const errs = validate(form);
|
||||||
|
if (Object.keys(errs).length) { setErrors(errs); return; }
|
||||||
|
setSaving(true);
|
||||||
|
setApiError("");
|
||||||
|
const ok = await onSubmit(form, isNew);
|
||||||
|
setSaving(false);
|
||||||
|
if (ok) onClose();
|
||||||
|
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle = (field: keyof ClientAddressFormErrors): React.CSSProperties => ({
|
||||||
|
...S.input,
|
||||||
|
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="addr-modal-title"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
background: "rgba(15,23,42,0.55)",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 520,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRadius: "var(--radius-2xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "1.25rem 1.5rem",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.3em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isNew ? "إضافة عنوان" : "تعديل عنوان"}
|
||||||
|
</p>
|
||||||
|
<h2
|
||||||
|
id="addr-modal-title"
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: "4px 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isNew ? "عنوان جديد" : editAddress?.label}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="إغلاق"
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 18,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
noValidate
|
||||||
|
style={{
|
||||||
|
padding: "1.5rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{apiError && (
|
||||||
|
<Alert type="error" message={apiError} onClose={() => setApiError("")} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label + isPrimary */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr auto",
|
||||||
|
gap: "0.75rem",
|
||||||
|
alignItems: "end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={S.label}>
|
||||||
|
نوع العنوان *
|
||||||
|
<select
|
||||||
|
ref={firstRef}
|
||||||
|
style={{ ...inputStyle("label"), cursor: "pointer" }}
|
||||||
|
value={form.label}
|
||||||
|
onChange={set("label")}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="">اختر النوع</option>
|
||||||
|
{LABEL_PRESETS.map((l) => (
|
||||||
|
<option key={l} value={l}>{l}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.label && <span style={S.errorText}>{errors.label}</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
paddingBottom: 2,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isPrimary}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((p) => ({ ...p, isPrimary: e.target.checked }))
|
||||||
|
}
|
||||||
|
style={{ width: 14, height: 14, cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
عنوان رئيسي
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Street */}
|
||||||
|
<label style={S.label}>
|
||||||
|
الشارع / العنوان التفصيلي *
|
||||||
|
<input
|
||||||
|
style={inputStyle("street")}
|
||||||
|
value={form.street}
|
||||||
|
onChange={set("street")}
|
||||||
|
placeholder="شارع الملك فهد، مبنى 12"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{errors.street && <span style={S.errorText}>{errors.street}</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* City + State */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={S.label}>
|
||||||
|
المدينة *
|
||||||
|
<input
|
||||||
|
style={inputStyle("city")}
|
||||||
|
value={form.city}
|
||||||
|
onChange={set("city")}
|
||||||
|
placeholder="الرياض"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{errors.city && <span style={S.errorText}>{errors.city}</span>}
|
||||||
|
</label>
|
||||||
|
<label style={S.label}>
|
||||||
|
المنطقة *
|
||||||
|
<input
|
||||||
|
style={inputStyle("state")}
|
||||||
|
value={form.state}
|
||||||
|
onChange={set("state")}
|
||||||
|
placeholder="منطقة الرياض"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{errors.state && <span style={S.errorText}>{errors.state}</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Postal code + Country */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={S.label}>
|
||||||
|
الرمز البريدي *
|
||||||
|
<input
|
||||||
|
style={inputStyle("postalCode")}
|
||||||
|
value={form.postalCode}
|
||||||
|
onChange={set("postalCode")}
|
||||||
|
placeholder="11564"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{errors.postalCode && (
|
||||||
|
<span style={S.errorText}>{errors.postalCode}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label style={S.label}>
|
||||||
|
الدولة *
|
||||||
|
<input
|
||||||
|
style={inputStyle("country")}
|
||||||
|
value={form.country}
|
||||||
|
onChange={set("country")}
|
||||||
|
placeholder="المملكة العربية السعودية"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{errors.country && <span style={S.errorText}>{errors.country}</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
paddingTop: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.25rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
cursor: saving ? "not-allowed" : "pointer",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.5rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "none",
|
||||||
|
background: saving
|
||||||
|
? "var(--color-brand-400)"
|
||||||
|
: "var(--color-brand-600)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: saving ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving && <Spinner size="sm" className="text-white" />}
|
||||||
|
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة العنوان" : "حفظ التغييرات"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
// src/Components/Client/Clienttable.tsx
|
||||||
|
// MIGRATED to ReusableTable — this is the one table in the project with
|
||||||
|
// extra behavior (a click-to-expand detail panel below the row), so it
|
||||||
|
// relies on ReusableTable's `renderExpanded` prop (added specifically to
|
||||||
|
// cover this case) instead of the plain `renderActions`/`onRowClick` pair
|
||||||
|
// every other table uses. AddressBadge and ClientDetailPanel are kept
|
||||||
|
// exactly as in the original — purpose-built layouts with no shared-UI
|
||||||
|
// equivalent, same rationale as the original file's comments.
|
||||||
|
|
||||||
import { useState } from "react";
|
import { IconBtn, ReusableTable, Button } from "../UI";
|
||||||
import { Button, EmptyState, IconBtn, Spinner } from "../UI";
|
import type { TableColumn } from "@/src/types/models";
|
||||||
import type { Client } from "@/src/types/client";
|
import type { Client } from "@/src/types/client";
|
||||||
|
|
||||||
// ── Address count badge ────────────────────────────────────────────────────
|
// ── Address count badge ────────────────────────────────────────────────────
|
||||||
function AddressBadge({ count, onClick }: { count: number; onClick: () => void }) {
|
function AddressBadge({
|
||||||
|
count,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
count: number;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
const hasAddr = count > 0;
|
const hasAddr = count > 0;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
onClick={(e) => {
|
||||||
title="إدارة العناوين"
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
title="ادارة العناوين"
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -29,25 +46,13 @@ function AddressBadge({ count, onClick }: { count: number; onClick: () => void }
|
|||||||
transition: "opacity 150ms",
|
transition: "opacity 150ms",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{count} {count === 1 ? "عنوان" : "عناوين"}
|
{count} {count === 1 ? "address" : "العناوين"}
|
||||||
{/* Small arrow icon to hint navigation */}
|
<i className="ti ti-chevron-right" style={{ fontSize: 10 }} aria-hidden="true" />
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<path d="M9 18l6-6-6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Client detail panel (inline expandable) ────────────────────────────────
|
// ── Client detail panel (the expandable content) ───────────────────────────
|
||||||
/**
|
|
||||||
* ClientDetailPanel — shown below the clicked row.
|
|
||||||
* Displays all client fields (name, email, phone, taxId, notes, createdAt)
|
|
||||||
* and provides a direct link to the addresses sub-page.
|
|
||||||
*
|
|
||||||
* @param client The client whose details are rendered.
|
|
||||||
* @param onGoAddresses Callback that navigates to /dashboard/clients/[id]/addresses.
|
|
||||||
* @param onEdit Opens the edit modal for this client.
|
|
||||||
*/
|
|
||||||
function ClientDetailPanel({
|
function ClientDetailPanel({
|
||||||
client,
|
client,
|
||||||
onGoAddresses,
|
onGoAddresses,
|
||||||
@@ -85,10 +90,8 @@ function ClientDetailPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
dir="rtl"
|
|
||||||
style={{
|
style={{
|
||||||
/* Slide-down visual treatment: left accent border in brand blue */
|
borderInlineStart: "3px solid var(--color-brand-600)",
|
||||||
borderRight: "3px solid var(--color-brand-600)",
|
|
||||||
background: "linear-gradient(135deg, #EFF6FF 0%, #F0F9FF 100%)",
|
background: "linear-gradient(135deg, #EFF6FF 0%, #F0F9FF 100%)",
|
||||||
borderBottom: "1px solid #BFDBFE",
|
borderBottom: "1px solid #BFDBFE",
|
||||||
padding: "1.25rem 1.5rem",
|
padding: "1.25rem 1.5rem",
|
||||||
@@ -98,7 +101,6 @@ function ClientDetailPanel({
|
|||||||
animation: "detailFadeIn 180ms ease",
|
animation: "detailFadeIn 180ms ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Top row: name + quick actions ── */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -138,36 +140,29 @@ function ClientDetailPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexShrink: 0 }}>
|
<div style={{ display: "flex", gap: "0.5rem", flexShrink: 0 }}>
|
||||||
{/* Edit client */}
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<i className="ti ti-edit" style={{ fontSize: 12 }} aria-hidden="true" />
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
تعديل التفاصيل
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
تعديل البيانات
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/*
|
|
||||||
* "Addresses" button — KEY FEATURE:
|
|
||||||
* Navigates to /dashboard/clients/[clientId]/addresses
|
|
||||||
*/}
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => { e.stopPropagation(); onGoAddresses(); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onGoAddresses();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<i className="ti ti-map-pin" style={{ fontSize: 12 }} aria-hidden="true" />
|
||||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
|
||||||
<circle cx="12" cy="10" r="3" />
|
|
||||||
</svg>
|
|
||||||
العناوين
|
العناوين
|
||||||
{client.addresses?.length !== undefined && (
|
{client.addresses?.length !== undefined && (
|
||||||
<span
|
<span
|
||||||
@@ -187,7 +182,6 @@ function ClientDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Info grid ── */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -198,7 +192,7 @@ function ClientDetailPanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{infoItem(
|
{infoItem(
|
||||||
"البريد الإلكتروني",
|
"البريد الالكتروني",
|
||||||
<a
|
<a
|
||||||
href={`mailto:${client.email}`}
|
href={`mailto:${client.email}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -207,27 +201,26 @@ function ClientDetailPanel({
|
|||||||
{client.email}
|
{client.email}
|
||||||
</a>,
|
</a>,
|
||||||
)}
|
)}
|
||||||
{infoItem("الهاتف", client.phone)}
|
{infoItem("رقم الجوال", client.phone)}
|
||||||
{infoItem(
|
{infoItem(
|
||||||
"تاريخ الإضافة",
|
"تاريخ الانشاء",
|
||||||
new Date(client.createdAt).toLocaleDateString("ar-SA", {
|
new Date(client.createdAt).toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
{infoItem(
|
{infoItem(
|
||||||
"آخر تحديث",
|
"اخر تحديث",
|
||||||
new Date(client.updatedAt).toLocaleDateString("ar-SA", {
|
new Date(client.updatedAt).toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
{client.notes && infoItem("ملاحظات", client.notes)}
|
{client.notes && infoItem("Notes", client.notes)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyframe animation injected via a style tag (scoped) */}
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes detailFadeIn {
|
@keyframes detailFadeIn {
|
||||||
from { opacity: 0; transform: translateY(-6px); }
|
from { opacity: 0; transform: translateY(-6px); }
|
||||||
@@ -238,26 +231,6 @@ function ClientDetailPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared card & header styles ────────────────────────────────────────────
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
overflow: "hidden",
|
|
||||||
boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Props ──────────────────────────────────────────────────────────────────
|
// ── Props ──────────────────────────────────────────────────────────────────
|
||||||
interface ClientTableProps {
|
interface ClientTableProps {
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
@@ -269,7 +242,6 @@ interface ClientTableProps {
|
|||||||
onDelete: (client: Client) => void;
|
onDelete: (client: Client) => void;
|
||||||
onAddFirst: () => void;
|
onAddFirst: () => void;
|
||||||
onPageChange: (p: number) => void;
|
onPageChange: (p: number) => void;
|
||||||
/** Navigate to address management for a client */
|
|
||||||
onManageAddresses: (client: Client) => void;
|
onManageAddresses: (client: Client) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,311 +258,155 @@ export function ClientTable({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onManageAddresses,
|
onManageAddresses,
|
||||||
}: ClientTableProps) {
|
}: ClientTableProps) {
|
||||||
/**
|
const columns: TableColumn<Client>[] = [
|
||||||
* expandedId — tracks which client row is currently expanded.
|
{
|
||||||
* Clicking the same row again collapses it (toggle behaviour).
|
key: "name",
|
||||||
*/
|
header: "اسم العميل",
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
width: "2fr",
|
||||||
|
render: (c, _i, isExpanded) => (
|
||||||
const handleRowClick = (client: Client) => {
|
<div>
|
||||||
// Toggle: collapse if already open, expand otherwise
|
<p
|
||||||
setExpandedId((prev) => (prev === client.id ? null : client.id));
|
style={{
|
||||||
};
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
<i
|
||||||
|
className="ti ti-chevron-right"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
flexShrink: 0,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
||||||
|
transition: "transform 200ms",
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
{c.taxId && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: 2,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.taxId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "email",
|
||||||
|
header: "البريد الالكتروني",
|
||||||
|
width: "1.5fr",
|
||||||
|
render: (c) => (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.email}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "phone",
|
||||||
|
header: "رقم الجوال",
|
||||||
|
width: "1.5fr",
|
||||||
|
render: (c) => (
|
||||||
|
<span style={{ color: "var(--color-text-secondary)" }}>{c.phone}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "addresses",
|
||||||
|
header: "العناوين",
|
||||||
|
width: "1fr",
|
||||||
|
render: (c) => (
|
||||||
|
<AddressBadge
|
||||||
|
count={c.addresses?.length ?? 0}
|
||||||
|
onClick={() => onManageAddresses(c)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
header: "تاريخ الانشاء",
|
||||||
|
width: "1fr",
|
||||||
|
align: "center",
|
||||||
|
render: (c) => (
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
{new Date(c.createdAt).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardStyle}>
|
<ReusableTable
|
||||||
{/* Column headers */}
|
columns={columns}
|
||||||
<div
|
data={clients}
|
||||||
dir="rtl"
|
loading={loading}
|
||||||
style={{
|
search={search}
|
||||||
display: "grid",
|
page={page}
|
||||||
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
|
pages={pages}
|
||||||
...thStyle,
|
onPageChange={onPageChange}
|
||||||
}}
|
actionsWidth="80px"
|
||||||
>
|
emptyIcon="👥"
|
||||||
<span>العميل</span>
|
emptyTitle={
|
||||||
<span>البريد الإلكتروني</span>
|
search ? `No results for "${search}"` : "No clients to display."
|
||||||
<span>الهاتف</span>
|
}
|
||||||
<span>العناوين</span>
|
emptyAction={
|
||||||
<span style={{ textAlign: "center" }}>تاريخ الإضافة</span>
|
!search && (
|
||||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||||
</div>
|
Add first client
|
||||||
|
</Button>
|
||||||
{/* Loading */}
|
)
|
||||||
{loading ? (
|
}
|
||||||
<div
|
renderActions={(c) => (
|
||||||
style={{
|
<>
|
||||||
display: "flex",
|
<IconBtn
|
||||||
alignItems: "center",
|
title={`Edit ${c.name}`}
|
||||||
justifyContent: "center",
|
color="#1D4ED8"
|
||||||
gap: 12,
|
bg="#EFF6FF"
|
||||||
padding: "4rem 0",
|
borderColor="#BFDBFE"
|
||||||
color: "var(--color-text-muted)",
|
onClick={() => onEdit(c)}
|
||||||
}}
|
>
|
||||||
>
|
<i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
</IconBtn>
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
<IconBtn
|
||||||
</div>
|
title={`Delete ${c.name}`}
|
||||||
) : clients.length === 0 ? (
|
color="#DC2626"
|
||||||
/* Empty state */
|
bg="#FEF2F2"
|
||||||
<EmptyState
|
borderColor="#FECACA"
|
||||||
icon="👥"
|
onClick={() => onDelete(c)}
|
||||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء لعرضهم."}
|
>
|
||||||
action={
|
<i className="ti ti-trash" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
!search && (
|
</IconBtn>
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
</>
|
||||||
أضف أول عميل
|
)}
|
||||||
</Button>
|
renderExpanded={(c) => (
|
||||||
)
|
<ClientDetailPanel
|
||||||
}
|
client={c}
|
||||||
|
onGoAddresses={() => onManageAddresses(c)}
|
||||||
|
onEdit={() => onEdit(c)}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
/* Data rows */
|
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
|
||||||
{clients.map((c, i) => {
|
|
||||||
const isExpanded = expandedId === c.id;
|
|
||||||
return (
|
|
||||||
<li key={c.id}>
|
|
||||||
{/* ── Main row ── */}
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-expanded={isExpanded}
|
|
||||||
aria-label={`${c.name} — انقر لعرض التفاصيل`}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") handleRowClick(c);
|
|
||||||
}}
|
|
||||||
onClick={() => handleRowClick(c)}
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.5rem",
|
|
||||||
padding: "0.875rem 1.5rem",
|
|
||||||
borderBottom: isExpanded
|
|
||||||
? "none"
|
|
||||||
: "1px solid var(--color-border)",
|
|
||||||
/* Highlight the expanded row with a brand-tinted background */
|
|
||||||
background: isExpanded
|
|
||||||
? "#EFF6FF"
|
|
||||||
: i % 2 !== 0
|
|
||||||
? "var(--color-surface-muted)"
|
|
||||||
: "transparent",
|
|
||||||
/* Left accent border when expanded (RTL: border-right in visual) */
|
|
||||||
borderRight: isExpanded
|
|
||||||
? "3px solid var(--color-brand-600)"
|
|
||||||
: "3px solid transparent",
|
|
||||||
fontSize: 13,
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "background 120ms, border-color 120ms",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Name */}
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
margin: 0,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.name}
|
|
||||||
{/* Chevron indicator — rotates when expanded */}
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
|
||||||
transition: "transform 200ms",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<path d="M9 18l6-6-6-6" />
|
|
||||||
</svg>
|
|
||||||
</p>
|
|
||||||
{c.taxId && (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
marginTop: 2,
|
|
||||||
fontFamily: "var(--font-mono)",
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.taxId}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-mono)",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#2563EB",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.email}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Phone */}
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
|
||||||
{c.phone}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/*
|
|
||||||
* Address count badge — clicking it navigates directly to
|
|
||||||
* /dashboard/clients/[clientId]/addresses (bypasses the detail panel).
|
|
||||||
*/}
|
|
||||||
<AddressBadge
|
|
||||||
count={c.addresses?.length ?? 0}
|
|
||||||
onClick={() => onManageAddresses(c)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Created at */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{new Date(c.createdAt).toLocaleDateString("ar-SA", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Row action buttons */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<IconBtn
|
|
||||||
title={`تعديل ${c.name}`}
|
|
||||||
color="#1D4ED8"
|
|
||||||
bg="#EFF6FF"
|
|
||||||
borderColor="#BFDBFE"
|
|
||||||
onClick={() => onEdit(c)}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
<IconBtn
|
|
||||||
title={`حذف ${c.name}`}
|
|
||||||
color="#DC2626"
|
|
||||||
bg="#FEF2F2"
|
|
||||||
borderColor="#FECACA"
|
|
||||||
onClick={() => onDelete(c)}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" />
|
|
||||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/*
|
|
||||||
* ── Expandable detail panel ──
|
|
||||||
* Rendered inline below the row when isExpanded is true.
|
|
||||||
* Contains full client metadata + "العناوين" navigation button.
|
|
||||||
*
|
|
||||||
* TEST: Click any row → panel slides in with client details.
|
|
||||||
* Click "العناوين" → router.push to /dashboard/clients/[id]/addresses.
|
|
||||||
* Click same row again → panel collapses.
|
|
||||||
*/}
|
|
||||||
{isExpanded && (
|
|
||||||
<ClientDetailPanel
|
|
||||||
client={c}
|
|
||||||
onGoAddresses={() => onManageAddresses(c)}
|
|
||||||
onEdit={() => onEdit(c)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
{/* Pagination */}
|
|
||||||
{pages > 1 && (
|
|
||||||
<div
|
|
||||||
dir="rtl"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
borderTop: "1px solid var(--color-border)",
|
|
||||||
padding: "0.875rem 1.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
صفحة{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>{page}</strong>{" "}
|
|
||||||
من{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
label: "السابق",
|
|
||||||
action: () => onPageChange(Math.max(1, page - 1)),
|
|
||||||
disabled: page === 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "التالي",
|
|
||||||
action: () => onPageChange(Math.min(pages, page + 1)),
|
|
||||||
disabled: page === pages,
|
|
||||||
},
|
|
||||||
].map((btn) => (
|
|
||||||
<Button
|
|
||||||
key={btn.label}
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={btn.action}
|
|
||||||
disabled={btn.disabled}
|
|
||||||
>
|
|
||||||
{btn.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
171
src/Components/Client/Deleteconfirmmodal.tsx
Normal file
171
src/Components/Client/Deleteconfirmmodal.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import type { Client } from "@/src/types/client";
|
||||||
|
|
||||||
|
interface DeleteConfirmModalProps {
|
||||||
|
client: Client;
|
||||||
|
deleting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirmModal({
|
||||||
|
client,
|
||||||
|
deleting,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteConfirmModalProps) {
|
||||||
|
// Close on Escape — identical to User/DeleteConfirmModal
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onCancel();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="del-client-title"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onCancel();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 60,
|
||||||
|
background: "rgba(15,23,42,0.55)",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 400,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
||||||
|
padding: "2rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* أيقونة الحذف */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
margin: "0 auto",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#FEF2F2",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#DC2626"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
id="del-client-title"
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
حذف العميل
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
هل أنت متأكد من حذف{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{client.name}
|
||||||
|
</strong>
|
||||||
|
؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
cursor: deleting ? "not-allowed" : "pointer",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "none",
|
||||||
|
background: "#DC2626",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: deleting ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
opacity: deleting ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleting && <Spinner size="sm" className="text-white" />}
|
||||||
|
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/Components/Client/Toast.tsx
Normal file
5
src/Components/Client/Toast.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
|
||||||
|
// Re-export the canonical implementation so existing imports keep working.
|
||||||
|
export { Toast } from "@/src/Components/UI";
|
||||||
|
export type { ToastNotification as Notification } from "@/src/Components/UI";
|
||||||
@@ -1,112 +1,76 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
// src/Components/Client/archive/ArchivedClientTable.tsx
|
||||||
|
// MIGRATED to ReusableTable + ActionButtons. View-only, no row click in
|
||||||
|
// the original.
|
||||||
|
|
||||||
|
import { Badge, ReusableTable, ActionButtons } from "../../UI";
|
||||||
|
import type { TableColumn } from "@/src/types/models";
|
||||||
import type { ArchivedClient } from "@/src/types/client";
|
import type { ArchivedClient } from "@/src/types/client";
|
||||||
|
|
||||||
// ── card / header styles ─────────────────────────────────────────────────────
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Props ────────────────────────────────────────────────────────────────────
|
|
||||||
interface ArchivedClientTableProps {
|
interface ArchivedClientTableProps {
|
||||||
clients: ArchivedClient[];
|
clients: ArchivedClient[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
search: string;
|
search: string;
|
||||||
page: number;
|
page: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
onView: (client: ArchivedClient) => void;
|
onView: (client: ArchivedClient) => void;
|
||||||
onPageChange: (p: number) => void;
|
onPageChange: (p: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── main table ───────────────────────────────────────────────────────────────
|
|
||||||
export function ArchivedClientTable({ clients, loading, search, page, pages, onView, onPageChange }: ArchivedClientTableProps) {
|
export function ArchivedClientTable({ clients, loading, search, page, pages, onView, onPageChange }: ArchivedClientTableProps) {
|
||||||
|
const columns: TableColumn<ArchivedClient>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "اسم العميل",
|
||||||
|
width: "2fr",
|
||||||
|
render: (c) => (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{c.name}</p>
|
||||||
|
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{c.phone}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "email",
|
||||||
|
header: "البريد الالكتروني",
|
||||||
|
width: "1.5fr",
|
||||||
|
render: (c) => <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{c.email}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "الحالة",
|
||||||
|
width: "1fr",
|
||||||
|
render: (c) => <Badge label={c.isActive ? "Active" : "Disabled"} color={c.isActive ? "green" : "red"} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
header: "تاريخ الانشاء",
|
||||||
|
width: "1fr",
|
||||||
|
align: "center",
|
||||||
|
render: (c) => new Date(c.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
header: "تاريخ التعديل",
|
||||||
|
width: "1fr",
|
||||||
|
align: "center",
|
||||||
|
render: (c) => new Date(c.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardStyle}>
|
<ReusableTable
|
||||||
{/* column headers */}
|
columns={columns}
|
||||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
|
data={clients}
|
||||||
<span>العميل</span>
|
loading={loading}
|
||||||
<span>البريد الإلكتروني</span>
|
search={search}
|
||||||
<span>الحالة</span>
|
page={page}
|
||||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
pages={pages}
|
||||||
<span style={{ textAlign: "center" }}>آخر تحديث</span>
|
onPageChange={onPageChange}
|
||||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
emptyIcon="👥"
|
||||||
</div>
|
emptyDescription={search ? `No results for "${search}"` : "No archived clients."}
|
||||||
|
renderActions={(c) => <ActionButtons itemLabel={c.name} onView={() => onView(c)} />}
|
||||||
{/* loading state */}
|
/>
|
||||||
{loading ? (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
|
||||||
</div>
|
|
||||||
) : clients.length === 0 ? (
|
|
||||||
/* empty state */
|
|
||||||
<EmptyState
|
|
||||||
icon="👥"
|
|
||||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء في الأرشيف."}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
/* data rows */
|
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
|
||||||
{clients.map((c, i) => (
|
|
||||||
<li key={c.id} style={{
|
|
||||||
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px",
|
|
||||||
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
|
||||||
fontSize: 13,
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{c.name}</p>
|
|
||||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{c.phone}</p>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{c.email}</span>
|
|
||||||
<Badge label={c.isActive ? "نشط" : "معطل"} color={c.isActive ? "green" : "red"} />
|
|
||||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
|
||||||
{new Date(c.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
|
||||||
</span>
|
|
||||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
|
||||||
{new Date(c.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
|
||||||
{/* view only — delete/restore not implemented yet, matches archived users */}
|
|
||||||
<IconBtn title={`عرض ${c.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(c)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* pagination */}
|
|
||||||
{pages > 1 && (
|
|
||||||
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
{[
|
|
||||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
|
||||||
].map(btn => (
|
|
||||||
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}>
|
|
||||||
{btn.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,9 +42,7 @@ export function ArchivedClientsModal({ onClose }: ArchivedClientsModalProps) {
|
|||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
icon={
|
icon={
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/Components/Client/index.ts
|
// src/Components/Client/index.ts
|
||||||
// CHANGE: removed DeleteConfirmModal export (Issue 1).
|
// CHANGE: removed DeleteConfirmModal export (Issue 1).
|
||||||
export { ClientFormModal } from "./Clientformmodal";
|
export { ClientFormModal } from "./Clientformmodal";
|
||||||
export { ClientTable } from "./Clienttable";
|
export { ClientTable } from "./ClientTable";
|
||||||
export { AddressFormModal } from "../Client_Adress/Addressformmodal";
|
export { AddressFormModal } from "../Client_Adress/Addressformmodal";
|
||||||
|
|||||||
284
src/Components/Client_Adress/AddressDetailModal.tsx
Normal file
284
src/Components/Client_Adress/AddressDetailModal.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Modal } from "../UI";
|
||||||
|
import type { ClientAddress } from "@/src/types/client_adresses";
|
||||||
|
|
||||||
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function labelIcon(label: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"فوترة": "💳",
|
||||||
|
"شحن": "📦",
|
||||||
|
"المقر الرئيسي": "🏢",
|
||||||
|
"فرع": "🏬",
|
||||||
|
"مستودع": "🏭",
|
||||||
|
billing: "💳",
|
||||||
|
shipping: "📦",
|
||||||
|
"head office": "🏢",
|
||||||
|
branch: "🏬",
|
||||||
|
warehouse: "🏭",
|
||||||
|
};
|
||||||
|
return map[label.toLowerCase()] ?? "📍";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sub-components ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
ltr = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
ltr?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
padding: "0.75rem 0",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.07em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
dir={ltr ? "ltr" : "rtl"}
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
fontFamily: ltr ? "var(--font-mono)" : "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Card header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "0.875rem 1.5rem",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 16 }} aria-hidden="true">{icon}</span>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card body */}
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
style={{ padding: "0 1.5rem" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Body content — was AddressDetails.tsx, now inlined here ───────────────
|
||||||
|
|
||||||
|
function AddressDetailsBody({ address }: { address: ClientAddress }) {
|
||||||
|
const { details, contactPerson, location } = address;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Identity card — label + branch */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
padding: "1.25rem 1.5rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
background: "var(--color-brand-50)",
|
||||||
|
border: "1px solid var(--color-brand-100)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 24,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{labelIcon(address.label)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{address.label}
|
||||||
|
</p>
|
||||||
|
{address.branchName && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "3px 0 0",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{address.branchName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address details */}
|
||||||
|
<SectionCard title="تفاصيل العنوان" icon="🗺️">
|
||||||
|
<DetailRow label="الشارع" value={details.street} />
|
||||||
|
<DetailRow label="المدينة" value={details.city} />
|
||||||
|
<DetailRow label="المنطقة / الولاية" value={details.state} />
|
||||||
|
<DetailRow label="الحي" value={details.district} />
|
||||||
|
<DetailRow label="رقم المبنى" value={details.buildingNo} ltr />
|
||||||
|
<DetailRow label="رقم الوحدة" value={details.unitNo} ltr />
|
||||||
|
<DetailRow label="الرقم الإضافي" value={details.additionalNo} ltr />
|
||||||
|
<DetailRow label="الرمز البريدي" value={details.zipCode} ltr />
|
||||||
|
<DetailRow label="الشقة / الطابق" value={details.apartment} />
|
||||||
|
<DetailRow label="الدولة" value={details.country} />
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Contact person — only when present */}
|
||||||
|
{(contactPerson?.name || contactPerson?.phone) && (
|
||||||
|
<SectionCard title="جهة الاتصال" icon="👤">
|
||||||
|
<DetailRow label="الاسم" value={contactPerson.name} />
|
||||||
|
<DetailRow label="رقم الهاتف" value={contactPerson.phone} ltr />
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coordinates */}
|
||||||
|
{location?.coordinates && (
|
||||||
|
<SectionCard title="الإحداثيات الجغرافية" icon="📡">
|
||||||
|
<DetailRow
|
||||||
|
label="خط الطول (Longitude)"
|
||||||
|
value={String(location.coordinates[0])}
|
||||||
|
ltr
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="خط العرض (Latitude)"
|
||||||
|
value={String(location.coordinates[1])}
|
||||||
|
ltr
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* System metadata */}
|
||||||
|
<SectionCard title="معلومات النظام" icon="🕐">
|
||||||
|
<DetailRow
|
||||||
|
label="تاريخ الإنشاء"
|
||||||
|
value={new Date(address.createdAt).toLocaleString("ar-SA", {
|
||||||
|
dateStyle: "long",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="آخر تحديث"
|
||||||
|
value={new Date(address.updatedAt).toLocaleString("ar-SA", {
|
||||||
|
dateStyle: "long",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main export — the modal ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface AddressDetailModalProps {
|
||||||
|
address: ClientAddress;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlike UserDetailModal, no fetch-by-id is needed here — the address list
|
||||||
|
// (useClientAddresses) already returns the full ClientAddress object with
|
||||||
|
// details/contactPerson/location, so we just render what we already have.
|
||||||
|
export function AddressDetailModal({ address, onClose }: AddressDetailModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title={address.label}
|
||||||
|
subtitle="تفاصيل العنوان"
|
||||||
|
onClose={onClose}
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
|
إغلاق
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AddressDetailsBody address={address} />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge, EmptyState, IconBtn, Spinner } from "../../UI";
|
|
||||||
|
import { Badge, IconBtn, ReusableTable } from "../../UI";
|
||||||
|
import type { TableColumn } from "@/src/types/models";
|
||||||
import type { ArchivedClientAddress } from "@/src/types/client_adresses";
|
import type { ArchivedClientAddress } from "@/src/types/client_adresses";
|
||||||
|
|
||||||
// ── card / header styles ─────────────────────────────────────────────────────
|
|
||||||
const cardStyle: React.CSSProperties = {
|
type Row = ArchivedClientAddress & { id: string };
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Props ────────────────────────────────────────────────────────────────────
|
// ── Props ────────────────────────────────────────────────────────────────────
|
||||||
interface ArchivedClientAddressesTableProps {
|
interface ArchivedClientAddressesTableProps {
|
||||||
@@ -25,73 +18,87 @@ interface ArchivedClientAddressesTableProps {
|
|||||||
|
|
||||||
// ── main table — no pagination footer: the endpoint returns the full set ─────
|
// ── main table — no pagination footer: the endpoint returns the full set ─────
|
||||||
export function ArchivedClientAddressesTable({ addresses, loading, search, onView }: ArchivedClientAddressesTableProps) {
|
export function ArchivedClientAddressesTable({ addresses, loading, search, onView }: ArchivedClientAddressesTableProps) {
|
||||||
return (
|
const rows: Row[] = addresses.map((a) => ({ ...a, id: a._id }));
|
||||||
<div style={cardStyle}>
|
|
||||||
{/* column headers */}
|
|
||||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "1.5fr 2fr 1.2fr 1fr 1fr 100px", ...thStyle }}>
|
|
||||||
<span>النوع</span>
|
|
||||||
<span>العنوان</span>
|
|
||||||
<span>جهة الاتصال</span>
|
|
||||||
<span>الحالة</span>
|
|
||||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
|
||||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* loading state */}
|
const columns: TableColumn<Row>[] = [
|
||||||
{loading ? (
|
{
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
key: "type",
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
header: "النوع",
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
width: "1.5fr",
|
||||||
|
render: (a) => (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{a.label}</p>
|
||||||
|
{a.branchName && (
|
||||||
|
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{a.branchName}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : addresses.length === 0 ? (
|
),
|
||||||
/* empty state */
|
},
|
||||||
<EmptyState
|
{
|
||||||
icon="📍"
|
key: "address",
|
||||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
|
header: "العنوان",
|
||||||
|
width: "2fr",
|
||||||
|
render: (a) => (
|
||||||
|
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||||
|
{a.details.street}، {a.details.city}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "contact",
|
||||||
|
header: "جهة الاتصال",
|
||||||
|
width: "1.2fr",
|
||||||
|
render: (a) => (
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||||
|
{a.contactPerson?.name ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "الحالة",
|
||||||
|
width: "1fr",
|
||||||
|
render: (a) => (
|
||||||
|
<Badge
|
||||||
|
label={a.isValidated ? "موثّق" : "غير موثّق"}
|
||||||
|
color={a.isValidated ? "green" : "slate"}
|
||||||
/>
|
/>
|
||||||
) : (
|
),
|
||||||
/* data rows */
|
},
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
{
|
||||||
{addresses.map((a, i) => (
|
key: "createdAt",
|
||||||
<li key={a._id} style={{
|
header: "تاريخ الإنشاء",
|
||||||
display: "grid", gridTemplateColumns: "1.5fr 2fr 1.2fr 1fr 1fr 100px",
|
width: "1fr",
|
||||||
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
|
render: (a) => (
|
||||||
borderBottom: "1px solid var(--color-border)",
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
{new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||||
fontSize: 13,
|
</span>
|
||||||
}}>
|
),
|
||||||
<div>
|
},
|
||||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{a.label}</p>
|
];
|
||||||
{a.branchName && (
|
|
||||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{a.branchName}</p>
|
return (
|
||||||
)}
|
<ReusableTable
|
||||||
</div>
|
columns={columns}
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
data={rows}
|
||||||
{a.details.street}، {a.details.city}
|
loading={loading}
|
||||||
</span>
|
search={search}
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
page={1}
|
||||||
{a.contactPerson?.name ?? "—"}
|
pages={1}
|
||||||
</span>
|
onPageChange={() => {}}
|
||||||
<Badge
|
actionsWidth="100px"
|
||||||
label={a.isValidated ? "موثّق" : "غير موثّق"}
|
onRowClick={onView}
|
||||||
color={a.isValidated ? "green" : "slate"}
|
emptyDescription={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
|
||||||
/>
|
renderActions={(a) => (
|
||||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
// view only — matches archived users/clients: no delete/restore yet
|
||||||
{new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
<IconBtn
|
||||||
</span>
|
title={`عرض ${a.label}`}
|
||||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
color="#059669" bg="#ECFDF5" borderColor="#A7F3D0"
|
||||||
{/* view only — matches archived users/clients: no delete/restore yet */}
|
onClick={() => onView(a)}
|
||||||
<IconBtn title={`عرض ${a.label}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(a)}>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
</IconBtn>
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -46,9 +46,7 @@ export function ArchivedClientsAddressesModal({ onClose, clientId }: ArchivedCli
|
|||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
icon={
|
icon={
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
0
src/Components/Client_Adress/clientAdress.tsx
Normal file
0
src/Components/Client_Adress/clientAdress.tsx
Normal file
90
src/Components/Dashboard/AlertsSection.tsx
Normal file
90
src/Components/Dashboard/AlertsSection.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { EmptyState, Spinner } from "@/src/Components/UI";
|
||||||
|
import { ENTITY_KPI_CONFIG } from "@/src/types/dashboard";
|
||||||
|
import type { DashboardAlert } from "@/src/types/dashboard";
|
||||||
|
|
||||||
|
function fmtRelative(iso: string): string {
|
||||||
|
const diffMs = Date.now() - new Date(iso).getTime();
|
||||||
|
const mins = Math.floor(diffMs / 60000);
|
||||||
|
if (mins < 1) return "الآن";
|
||||||
|
if (mins < 60) return `منذ ${mins} د`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `منذ ${hours} س`;
|
||||||
|
return `منذ ${Math.floor(hours / 24)} يوم`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertListItem({ alert }: { alert: DashboardAlert }) {
|
||||||
|
const entityCfg = ENTITY_KPI_CONFIG.find((e) => e.key === alert.entity);
|
||||||
|
const isCritical = alert.severity === "critical";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={entityCfg?.href ?? "/dashboard"}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 12,
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid var(--color-border-dark)",
|
||||||
|
background: "var(--color-surface-dark-raised)",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8, height: 8, borderRadius: "50%", flexShrink: 0,
|
||||||
|
background: isCritical ? "#F87171" : "#FBBF24",
|
||||||
|
boxShadow: isCritical ? "0 0 0 4px rgba(248,113,113,0.15)" : "0 0 0 4px rgba(251,191,36,0.15)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ flex: 1, fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
|
||||||
|
{alert.message}
|
||||||
|
</span>
|
||||||
|
{entityCfg && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10, fontWeight: 700, color: entityCfg.accent,
|
||||||
|
background: `${entityCfg.accent}1A`, border: `1px solid ${entityCfg.accent}40`,
|
||||||
|
borderRadius: "var(--radius-full)", padding: "0.15rem 0.55rem", whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entityCfg.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-dark-muted)", whiteSpace: "nowrap" }}>
|
||||||
|
{fmtRelative(alert.createdAt)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertsSectionProps {
|
||||||
|
alerts: DashboardAlert[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertsSection({ alerts, loading }: AlertsSectionProps) {
|
||||||
|
return (
|
||||||
|
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#FCA5A5", textAlign: "start" }}>
|
||||||
|
تنبيهات عاجلة
|
||||||
|
</p>
|
||||||
|
<h2 style={{ fontSize: "1.1rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>
|
||||||
|
يحتاج انتباهك
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{loading && <Spinner size="sm" className="text-cyan-400" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1.25rem", display: "flex", flexDirection: "column", gap: "0.625rem" }}>
|
||||||
|
{!loading && alerts.length === 0 && <EmptyState icon="✅" title="لا توجد تنبيهات حالياً" />}
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<AlertListItem key={alert.id} alert={alert} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/Components/Dashboard/KpiSection.tsx
Normal file
117
src/Components/Dashboard/KpiSection.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Spinner } from "@/src/Components/UI";
|
||||||
|
import type { EntityKpi } from "@/src/types/dashboard";
|
||||||
|
import { ENTITY_KPI_CONFIG, EMPTY_ENTITY_KPI } from "@/src/types/dashboard";
|
||||||
|
|
||||||
|
function KpiAnomalyBadge({ anomaly }: { anomaly: EntityKpi["anomaly"] }) {
|
||||||
|
if (!anomaly) return null;
|
||||||
|
const isCritical = anomaly.severity === "critical";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: `1px solid ${isCritical ? "rgba(248,113,113,0.35)" : "rgba(251,191,36,0.35)"}`,
|
||||||
|
background: isCritical ? "rgba(248,113,113,0.08)" : "rgba(251,191,36,0.08)",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: "50%", background: isCritical ? "#F87171" : "#FBBF24", flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: 11, color: isCritical ? "#FCA5A5" : "#FDE68A", lineHeight: 1.5 }}>
|
||||||
|
{anomaly.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: KpiCardCounts no longer takes/renders `active`/`pending` — it now
|
||||||
|
|
||||||
|
// only receives and displays `total`. The active/pending mini-boxes (and
|
||||||
|
// their inline styling) have been removed entirely; the total figure keeps
|
||||||
|
// its original styling/position exactly as before, so the card's overall
|
||||||
|
// layout is unchanged.
|
||||||
|
function KpiCardCounts({ total }: { total: number }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: "0.875rem" }}>
|
||||||
|
<p style={{ fontSize: "2rem", fontWeight: 700, color: "var(--color-text-dark-primary)", fontFamily: "var(--font-mono)", margin: 0, textAlign: "start" }}>
|
||||||
|
{total.toLocaleString("ar-SA")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ entity }: { entity: EntityKpi }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={entity.href}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
borderRadius: "var(--radius-2xl)",
|
||||||
|
border: "1px solid var(--color-border-dark)",
|
||||||
|
background: "var(--color-surface-dark-card)",
|
||||||
|
padding: "1.25rem",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
textDecoration: "none",
|
||||||
|
transition: "transform 150ms, box-shadow 150ms",
|
||||||
|
}}
|
||||||
|
className="hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(6,182,212,.12)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40, height: 40, borderRadius: "var(--radius-lg)",
|
||||||
|
background: `${entity.accent}1A`, border: `1px solid ${entity.accent}40`,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className={`ti ti-${entity.icon}`} style={{ fontSize: 18, color: entity.accent }} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)", margin: 0 }}>
|
||||||
|
{entity.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<i className="ti ti-chevron-left" style={{ fontSize: 14, color: "var(--color-text-dark-muted)" }} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 9: only `total` is passed now — `active`/`pending` props removed
|
||||||
|
from this call site along with the corresponding fields on EntityKpi. */}
|
||||||
|
<KpiCardCounts total={entity.total} />
|
||||||
|
<KpiAnomalyBadge anomaly={entity.anomaly} />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiSectionProps {
|
||||||
|
entities: EntityKpi[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiSection({ entities, loading }: KpiSectionProps) {
|
||||||
|
// Before the overview resolves, render the static config as zeroed cards
|
||||||
|
// so the grid never collapses to nothing.
|
||||||
|
|
||||||
|
const list = entities.length > 0 ? entities : ENTITY_KPI_CONFIG.map((cfg) => ({ ...cfg, ...EMPTY_ENTITY_KPI }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#67E8F9", margin: 0 }}>
|
||||||
|
نظرة عامة
|
||||||
|
</p>
|
||||||
|
{loading && <Spinner size="sm" className="text-cyan-400" />}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))" }}>
|
||||||
|
{list.map((entity) => (
|
||||||
|
<KpiCard key={entity.key} entity={entity} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/Components/Dashboard/QuickAccessFooter.tsx
Normal file
42
src/Components/Dashboard/QuickAccessFooter.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ENTITY_KPI_CONFIG } from "@/src/types/dashboard";
|
||||||
|
|
||||||
|
export function QuickAccessFooter() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-2xl)",
|
||||||
|
border: "1px solid var(--color-border-dark)",
|
||||||
|
background: "var(--color-surface-dark-card)",
|
||||||
|
padding: "1rem 1.25rem",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.625rem",
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ENTITY_KPI_CONFIG.map((entity) => (
|
||||||
|
<Link
|
||||||
|
key={entity.key}
|
||||||
|
href={entity.href}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex", alignItems: "center", gap: 6, flexShrink: 0,
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
border: "1px solid var(--color-border-dark)",
|
||||||
|
background: "var(--color-surface-dark-raised)",
|
||||||
|
padding: "0.45rem 0.875rem",
|
||||||
|
fontSize: 12, fontWeight: 600,
|
||||||
|
color: "var(--color-text-dark-primary)",
|
||||||
|
textDecoration: "none",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className={`ti ti-${entity.icon}`} style={{ fontSize: 14, color: entity.accent }} aria-hidden="true" />
|
||||||
|
{entity.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/Components/Dashboard/RecentActivityPanel.tsx
Normal file
71
src/Components/Dashboard/RecentActivityPanel.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { EmptyState, Spinner } from "@/src/Components/UI";
|
||||||
|
import { ActionBadge } from "@/src/Components/audit/AuditTable";
|
||||||
|
import type { AuditLog } from "@/src/types/audit";
|
||||||
|
|
||||||
|
function fmtDateTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString("ar-SA", { dateStyle: "short", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityItem({ log }: { log: AuditLog }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10,
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid var(--color-border-dark)",
|
||||||
|
background: "var(--color-surface-dark-raised)",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3" style={{ minWidth: 0 }}>
|
||||||
|
<ActionBadge action={log.action} />
|
||||||
|
<p style={{ fontSize: 12, color: "var(--color-text-dark-primary)", margin: 0, textAlign: "start" }}>
|
||||||
|
{log.userName ?? log.userId ?? "نظام"}{log.module ? ` — ${log.module}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-dark-muted)", whiteSpace: "nowrap" }}>
|
||||||
|
{fmtDateTime(log.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentActivityPanelProps {
|
||||||
|
logs: AuditLog[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentActivityPanel({ logs, loading }: RecentActivityPanelProps) {
|
||||||
|
return (
|
||||||
|
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#C4B5FD", textAlign: "start" }}>
|
||||||
|
سجل النشاط
|
||||||
|
</p>
|
||||||
|
<h2 style={{ fontSize: "1.1rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>
|
||||||
|
آخر الأنشطة
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{loading && <Spinner size="sm" className="text-cyan-400" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1.25rem", display: "flex", flexDirection: "column", gap: "0.625rem" }}>
|
||||||
|
{!loading && logs.length === 0 && <EmptyState icon="🕘" title="لا يوجد نشاط حديث" />}
|
||||||
|
{logs.slice(0, 8).map((log) => (
|
||||||
|
<ActivityItem key={log.id} log={log} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<Link href="/dashboard/audit" style={{ display: "inline-flex", alignItems: "center", gap: 6, marginTop: "1rem", fontSize: 12, fontWeight: 600, color: "#67E8F9", textDecoration: "none" }}>
|
||||||
|
عرض سجل التدقيق بالكامل
|
||||||
|
<i className="ti ti-arrow-left" style={{ fontSize: 12 }} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/Components/Dashboard/TrendChartsPanel.tsx
Normal file
81
src/Components/Dashboard/TrendChartsPanel.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EmptyState, Spinner } from "@/src/Components/UI";
|
||||||
|
import type { TrendSeries } from "@/src/types/dashboard";
|
||||||
|
|
||||||
|
function MiniTrendChart({ series }: { series: TrendSeries }) {
|
||||||
|
const { points, deltaPercent, title } = series;
|
||||||
|
if (!points.length) return null;
|
||||||
|
|
||||||
|
const width = 300;
|
||||||
|
const height = 64;
|
||||||
|
const values = points.map((p) => p.value);
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
const min = Math.min(...values, 0);
|
||||||
|
const range = max - min || 1;
|
||||||
|
|
||||||
|
const coords = points
|
||||||
|
.map((p, i) => {
|
||||||
|
const x = (i / (points.length - 1 || 1)) * width;
|
||||||
|
const y = height - ((p.value - min) / range) * height;
|
||||||
|
return `${x},${y}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const isUp = deltaPercent >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "1rem" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p style={{ fontSize: 12, color: "var(--color-text-dark-muted)", margin: 0 }}>{title}</p>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11, fontWeight: 700,
|
||||||
|
color: isUp ? "#6EE7B7" : "#FCA5A5",
|
||||||
|
background: isUp ? "rgba(52,211,153,0.1)" : "rgba(248,113,113,0.1)",
|
||||||
|
border: `1px solid ${isUp ? "rgba(52,211,153,0.3)" : "rgba(248,113,113,0.3)"}`,
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
padding: "0.1rem 0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isUp ? "▲" : "▼"} {Math.abs(deltaPercent).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} width="100%" height="96" preserveAspectRatio="none" style={{ marginTop: 8 }}>
|
||||||
|
<polyline points={coords} fill="none" stroke={isUp ? "#34D399" : "#F87171"} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrendChartsPanelProps {
|
||||||
|
series: TrendSeries[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrendChartsPanel({ series, loading }: TrendChartsPanelProps) {
|
||||||
|
return (
|
||||||
|
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#6EE7B7", textAlign: "start" }}>
|
||||||
|
الاتجاهات
|
||||||
|
</p>
|
||||||
|
<h2 style={{ fontSize: "1.1rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>
|
||||||
|
أداء آخر ٧ أيام
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{loading && <Spinner size="sm" className="text-cyan-400" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1.25rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
|
{!loading && series.length === 0 && (
|
||||||
|
<EmptyState icon="📈" title="لا توجد بيانات اتجاه بعد" description="سيتم عرض الرسوم البيانية عند توفر بيانات كافية." />
|
||||||
|
)}
|
||||||
|
{series.map((s) => (
|
||||||
|
<MiniTrendChart key={s.key} series={s} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/Components/Dashboard/index.ts
Normal file
5
src/Components/Dashboard/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { KpiSection } from "./KpiSection";
|
||||||
|
export { AlertsSection } from "./AlertsSection";
|
||||||
|
export { RecentActivityPanel } from "./RecentActivityPanel";
|
||||||
|
export { TrendChartsPanel } from "./TrendChartsPanel";
|
||||||
|
export { QuickAccessFooter } from "./QuickAccessFooter";
|
||||||
100
src/Components/Driver/DriverDeleteModal.tsx
Normal file
100
src/Components/Driver/DriverDeleteModal.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import type { Driver } from "@/src/types/driver";
|
||||||
|
|
||||||
|
interface DriverDeleteModalProps {
|
||||||
|
driver: Driver;
|
||||||
|
deleting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DriverDeleteModal({ driver, deleting, onCancel, onConfirm }: DriverDeleteModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alertdialog" aria-modal="true" aria-labelledby="driver-del-title"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 60,
|
||||||
|
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%", maxWidth: 420,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
||||||
|
padding: "2rem",
|
||||||
|
display: "flex", flexDirection: "column", gap: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52, margin: "0 auto", borderRadius: "50%",
|
||||||
|
background: "#FEF2F2", border: "1px solid #FECACA",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 id="driver-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
|
حذف السائق
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
||||||
|
هل أنت متأكد من حذف{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>{driver.name}</strong>
|
||||||
|
{" "}(
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||||
|
{driver.phone}
|
||||||
|
</span>
|
||||||
|
)؟ لا يمكن التراجع عن هذا الإجراء.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button type="button" onClick={onCancel} disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)", background: "var(--color-surface)",
|
||||||
|
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
|
||||||
|
cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)",
|
||||||
|
}}>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onConfirm} disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
||||||
|
border: "none", background: "#DC2626",
|
||||||
|
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||||
|
cursor: deleting ? "not-allowed" : "pointer",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1,
|
||||||
|
}}>
|
||||||
|
{deleting && <Spinner size="sm" className="text-white" />}
|
||||||
|
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -120,18 +120,7 @@ function PhotoCard({ url, label }: { url?: string | null; label: string }) {
|
|||||||
onError={() => setImgError(true)}
|
onError={() => setImgError(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<svg
|
<i className="ti ti-photo" style={{ fontSize: 28, color: "var(--color-text-hint)" }} aria-hidden="true" />
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="var(--color-text-hint)"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
>
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
||||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
||||||
<polyline points="21 15 16 10 5 21" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{url && !imgError && (
|
{url && !imgError && (
|
||||||
@@ -456,10 +445,7 @@ export function DriverDetailPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" variant="secondary" onClick={() => onEdit(driver)}>
|
<Button type="button" variant="secondary" onClick={() => onEdit(driver)}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
تعديل
|
تعديل
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
|||||||
import { Alert, Button, FileInput, Input, Modal, Select } from "../UI";
|
import { Alert, Button, FileInput, Input, Modal, Select } from "../UI";
|
||||||
import { get } from "@/src/services/api";
|
import { get } from "@/src/services/api";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { createDriverSchema, updateDriverSchema } from "@/src/validations/driver.validator";
|
import {
|
||||||
|
createDriverSchema,
|
||||||
|
updateDriverSchema,
|
||||||
|
} from "@/src/validations/driver.validator";
|
||||||
|
import { useEditFormSync } from "@/src/hooks/useEditFormSync";
|
||||||
import type {
|
import type {
|
||||||
Driver,
|
Driver,
|
||||||
CreateDriverPayload,
|
CreateDriverPayload,
|
||||||
@@ -105,7 +109,9 @@ export function DriverFormModal({
|
|||||||
// have structurally different required fields (union type yupResolver's
|
// have structurally different required fields (union type yupResolver's
|
||||||
// single-schema signature won't accept), and yup's inferred type for
|
// single-schema signature won't accept), and yup's inferred type for
|
||||||
// .optional() fields is structurally incompatible with DriverFormValues.
|
// .optional() fields is structurally incompatible with DriverFormValues.
|
||||||
resolver: yupResolver((isNew ? createDriverSchema : updateDriverSchema) as any) as any,
|
resolver: yupResolver(
|
||||||
|
(isNew ? createDriverSchema : updateDriverSchema) as any,
|
||||||
|
) as any,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: editDriver?.name ?? "",
|
name: editDriver?.name ?? "",
|
||||||
phone: editDriver?.phone ?? "",
|
phone: editDriver?.phone ?? "",
|
||||||
@@ -113,9 +119,12 @@ export function DriverFormModal({
|
|||||||
address: editDriver?.address ?? "",
|
address: editDriver?.address ?? "",
|
||||||
nationality: editDriver?.nationality ?? "",
|
nationality: editDriver?.nationality ?? "",
|
||||||
nationalIdType: editDriver?.nationalIdType ?? "",
|
nationalIdType: editDriver?.nationalIdType ?? "",
|
||||||
nationalId: (editDriver as Driver & { nationalId?: string })?.nationalId ?? "",
|
nationalId:
|
||||||
|
(editDriver as Driver & { nationalId?: string })?.nationalId ?? "",
|
||||||
nationalIdExpiry:
|
nationalIdExpiry:
|
||||||
(editDriver as Driver & { nationalIdExpiry?: string })?.nationalIdExpiry?.slice(0, 10) ?? "",
|
(
|
||||||
|
editDriver as Driver & { nationalIdExpiry?: string }
|
||||||
|
)?.nationalIdExpiry?.slice(0, 10) ?? "",
|
||||||
gosiNumber: editDriver?.gosiNumber ?? "",
|
gosiNumber: editDriver?.gosiNumber ?? "",
|
||||||
licenseNumber: editDriver?.licenseNumber ?? "",
|
licenseNumber: editDriver?.licenseNumber ?? "",
|
||||||
licenseType: editDriver?.licenseType ?? "",
|
licenseType: editDriver?.licenseType ?? "",
|
||||||
@@ -135,29 +144,30 @@ export function DriverFormModal({
|
|||||||
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
|
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
|
||||||
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// defaultValues.branchId was applied before this list existed, so the
|
|
||||||
// <select> had no matching <option> yet and silently fell back to "" —
|
|
||||||
// reapply the saved id now that the option actually exists in the DOM.
|
|
||||||
const savedBranchId = (editDriver as Driver & { branchId?: string })?.branchId;
|
|
||||||
|
|
||||||
if (branchesProp.length > 0) {
|
if (branchesProp.length > 0) {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => setBranches(branchesProp));
|
||||||
setBranches(branchesProp);
|
|
||||||
if (savedBranchId) setValue("branchId", savedBranchId);
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
get<{ data: { data: Branch[] } }>("branches?limit=100", token)
|
get<{ data: { data: Branch[] } }>("branches?limit=100", token)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const list = (res as unknown as { data: { data: Branch[] } }).data?.data ?? [];
|
const list =
|
||||||
|
(res as unknown as { data: { data: Branch[] } }).data?.data ?? [];
|
||||||
setBranches(list);
|
setBranches(list);
|
||||||
if (savedBranchId) setValue("branchId", savedBranchId);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* silently ignore */
|
/* silently ignore */
|
||||||
});
|
});
|
||||||
}, [branchesProp, editDriver, setValue]);
|
}, [branchesProp]);
|
||||||
|
|
||||||
|
// CHANGE: replaced the manual `if (savedBranchId) setValue("branchId", ...)`
|
||||||
|
// calls above with useEditFormSync — same fix, shared implementation.
|
||||||
|
useEditFormSync(
|
||||||
|
setValue,
|
||||||
|
"branchId",
|
||||||
|
(editDriver as Driver & { branchId?: string })?.branchId,
|
||||||
|
branches,
|
||||||
|
);
|
||||||
|
|
||||||
const [apiError, setApiError] = useState("");
|
const [apiError, setApiError] = useState("");
|
||||||
|
|
||||||
@@ -171,20 +181,27 @@ export function DriverFormModal({
|
|||||||
|
|
||||||
const submitHandler = useCallback(
|
const submitHandler = useCallback(
|
||||||
async (data: DriverFormValues) => {
|
async (data: DriverFormValues) => {
|
||||||
const payload: Record<string, unknown> = { name: data.name, phone: data.phone };
|
const payload: Record<string, unknown> = {
|
||||||
|
name: data.name,
|
||||||
|
phone: data.phone,
|
||||||
|
};
|
||||||
if (data.email) payload.email = data.email;
|
if (data.email) payload.email = data.email;
|
||||||
if (data.address) payload.address = data.address;
|
if (data.address) payload.address = data.address;
|
||||||
if (data.nationality) payload.nationality = data.nationality;
|
if (data.nationality) payload.nationality = data.nationality;
|
||||||
if (data.nationalIdType) payload.nationalIdType = data.nationalIdType;
|
if (data.nationalIdType) payload.nationalIdType = data.nationalIdType;
|
||||||
if (data.nationalId) payload.nationalId = data.nationalId;
|
if (data.nationalId) payload.nationalId = data.nationalId;
|
||||||
if (data.nationalIdExpiry) payload.nationalIdExpiry = toIsoDateTime(data.nationalIdExpiry);
|
if (data.nationalIdExpiry)
|
||||||
|
payload.nationalIdExpiry = toIsoDateTime(data.nationalIdExpiry);
|
||||||
if (data.gosiNumber) payload.gosiNumber = data.gosiNumber;
|
if (data.gosiNumber) payload.gosiNumber = data.gosiNumber;
|
||||||
if (data.licenseNumber) payload.licenseNumber = data.licenseNumber;
|
if (data.licenseNumber) payload.licenseNumber = data.licenseNumber;
|
||||||
if (data.licenseType) payload.licenseType = data.licenseType;
|
if (data.licenseType) payload.licenseType = data.licenseType;
|
||||||
if (data.licenseExpiry) payload.licenseExpiry = toIsoDateTime(data.licenseExpiry);
|
if (data.licenseExpiry)
|
||||||
if (data.driverCardNumber) payload.driverCardNumber = data.driverCardNumber;
|
payload.licenseExpiry = toIsoDateTime(data.licenseExpiry);
|
||||||
|
if (data.driverCardNumber)
|
||||||
|
payload.driverCardNumber = data.driverCardNumber;
|
||||||
if (data.driverCardType) payload.driverCardType = data.driverCardType;
|
if (data.driverCardType) payload.driverCardType = data.driverCardType;
|
||||||
if (data.driverCardExpiry) payload.driverCardExpiry = toIsoDateTime(data.driverCardExpiry);
|
if (data.driverCardExpiry)
|
||||||
|
payload.driverCardExpiry = toIsoDateTime(data.driverCardExpiry);
|
||||||
if (data.driverType) payload.driverType = data.driverType;
|
if (data.driverType) payload.driverType = data.driverType;
|
||||||
if (data.branchId) payload.branchId = data.branchId;
|
if (data.branchId) payload.branchId = data.branchId;
|
||||||
if (!isNew) payload.status = data.status;
|
if (!isNew) payload.status = data.status;
|
||||||
@@ -196,15 +213,23 @@ export function DriverFormModal({
|
|||||||
|
|
||||||
setApiError("");
|
setApiError("");
|
||||||
try {
|
try {
|
||||||
const ok = await onSubmit(payload as unknown as CreateDriverPayload, isNew);
|
const ok = await onSubmit(
|
||||||
|
payload as unknown as CreateDriverPayload,
|
||||||
|
isNew,
|
||||||
|
);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
setError("name", {
|
||||||
|
message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.",
|
||||||
|
});
|
||||||
setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.";
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.";
|
||||||
setError("name", { message });
|
setError("name", { message });
|
||||||
setApiError(message);
|
setApiError(message);
|
||||||
}
|
}
|
||||||
@@ -220,10 +245,15 @@ export function DriverFormModal({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="lg"
|
size="lg"
|
||||||
subtitle={isNew ? "إضافة سائق" : "تعديل سائق"}
|
subtitle={isNew ? "إضافة سائق" : "تعديل سائق"}
|
||||||
title={isNew ? "سائق جديد" : editDriver?.name ?? ""}
|
title={isNew ? "سائق جديد" : (editDriver?.name ?? "")}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
إلغاء
|
إلغاء
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||||
@@ -240,7 +270,11 @@ export function DriverFormModal({
|
|||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
{errors.name?.type === "manual" && (
|
{errors.name?.type === "manual" && (
|
||||||
<Alert type="error" message={errors.name.message ?? ""} onClose={() => setApiError("")} />
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={errors.name.message ?? ""}
|
||||||
|
onClose={() => setApiError("")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Section: Personal Info ── */}
|
{/* ── Section: Personal Info ── */}
|
||||||
@@ -252,7 +286,9 @@ export function DriverFormModal({
|
|||||||
placeholder="محمد عبدالله"
|
placeholder="محمد عبدالله"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
error={errors.name?.type !== "manual" ? errors.name?.message : undefined}
|
error={
|
||||||
|
errors.name?.type !== "manual" ? errors.name?.message : undefined
|
||||||
|
}
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -404,7 +440,9 @@ export function DriverFormModal({
|
|||||||
>
|
>
|
||||||
<option value="">اختر الفرع</option>
|
<option value="">اختر الفرع</option>
|
||||||
{branches.map((b) => (
|
{branches.map((b) => (
|
||||||
<option key={b.id} value={b.id}>{b.name}</option>
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -429,7 +467,14 @@ export function DriverFormModal({
|
|||||||
|
|
||||||
{/* ── Section: Photos ── */}
|
{/* ── Section: Photos ── */}
|
||||||
<p style={sectionHeadingStyle}>الصور والمستندات</p>
|
<p style={sectionHeadingStyle}>الصور والمستندات</p>
|
||||||
<p style={{ fontSize: 11, color: "var(--color-text-hint)", margin: "0.25rem 0 0", fontWeight: 400 }}>
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-hint)",
|
||||||
|
margin: "0.25rem 0 0",
|
||||||
|
fontWeight: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
جميع حقول الصور اختيارية
|
جميع حقول الصور اختيارية
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -440,25 +485,37 @@ export function DriverFormModal({
|
|||||||
name="photo"
|
name="photo"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FileInput label="صورة السائق" current={field.value} onChange={field.onChange} />
|
<FileInput
|
||||||
|
label="صورة السائق"
|
||||||
|
current={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
name="nationalPhoto"
|
name="nationalPhoto"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FileInput label="صورة الهوية" current={field.value} onChange={field.onChange} />
|
<FileInput
|
||||||
|
label="صورة الهوية"
|
||||||
|
current={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
name="driverCardPhoto"
|
name="driverCardPhoto"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FileInput label="صورة البطاقة" current={field.value} onChange={field.onChange} />
|
<FileInput
|
||||||
|
label="صورة البطاقة"
|
||||||
|
current={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/Components/Driver/DriverFormModalHelper.tsx
Normal file
55
src/Components/Driver/DriverFormModalHelper.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const inputBase: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
height: 40,
|
||||||
|
padding: "0 0.75rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FileInput({
|
||||||
|
label,
|
||||||
|
current,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
current: File | null;
|
||||||
|
onChange: (f: File | null) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{label}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => onChange(e.target.files?.[0] ?? null)}
|
||||||
|
style={{
|
||||||
|
...inputBase,
|
||||||
|
padding: "0.35rem 0.75rem",
|
||||||
|
height: "auto",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{current && (
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
{current.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -79,18 +79,7 @@ export function PhotoCard({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<svg
|
<i className="ti ti-photo" style={{ fontSize: 32, color: "var(--color-text-hint)" }} aria-hidden="true" />
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="var(--color-text-hint)"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
>
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
||||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
||||||
<polyline points="21 15 16 10 5 21" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{url && !imgError && (
|
{url && !imgError && (
|
||||||
@@ -110,4 +99,4 @@ export function PhotoCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
146
src/Components/Driver/DriverTable.tsx
Normal file
146
src/Components/Driver/DriverTable.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// src/Components/Driver/DriverTable.tsx
|
||||||
|
// NEW — previously this table was written inline inside
|
||||||
|
// app/dashboard/drivers/page.tsx (the only model whose table wasn't
|
||||||
|
// already split into its own component). Extracted here using
|
||||||
|
// ReusableTable + ActionButtons, matching every other *Table.tsx in the
|
||||||
|
// project. Same columns, same warn-highlighting for expiring
|
||||||
|
// license/national-ID dates, same row-click-to-view / edit+delete icons
|
||||||
|
// behavior as the original inline version.
|
||||||
|
//
|
||||||
|
// Integration: in app/dashboard/drivers/page.tsx, replace the entire
|
||||||
|
// `<div style={cardStyle}>...</div>` table block (and its `fmtDate` /
|
||||||
|
// `expirySoon` / `cardStyle` / `thStyle` / `ROW_GRID_COLUMNS` /
|
||||||
|
// `iconBtnBase` helpers, now unused) with:
|
||||||
|
//
|
||||||
|
// <DriverTable
|
||||||
|
// drivers={drivers}
|
||||||
|
// loading={loading}
|
||||||
|
// search={search}
|
||||||
|
// page={page}
|
||||||
|
// pages={pages}
|
||||||
|
// onPageChange={setPage}
|
||||||
|
// onRowClick={(d) => setSelectedDriverId(d.id)}
|
||||||
|
// onEdit={handleEdit}
|
||||||
|
// onDelete={handleDelete}
|
||||||
|
// />
|
||||||
|
|
||||||
|
import { ReusableTable, ActionButtons } from "../UI";
|
||||||
|
import { DRIVER_STATUS_MAP } from "@/src/types/driver";
|
||||||
|
import type { TableColumn } from "@/src/types/models";
|
||||||
|
import type { Driver } from "@/src/types/driver";
|
||||||
|
|
||||||
|
function fmtDate(iso?: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function expirySoon(iso?: string | null): boolean {
|
||||||
|
if (!iso) return false;
|
||||||
|
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DriverTableProps {
|
||||||
|
drivers: Driver[];
|
||||||
|
loading: boolean;
|
||||||
|
search: string;
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
onPageChange: (p: number) => void;
|
||||||
|
onRowClick: (driver: Driver) => void;
|
||||||
|
onEdit: (driver: Driver) => void;
|
||||||
|
onDelete: (driver: Driver) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DriverTable({
|
||||||
|
drivers, loading, search, page, pages,
|
||||||
|
onPageChange, onRowClick, onEdit, onDelete,
|
||||||
|
}: DriverTableProps) {
|
||||||
|
const columns: TableColumn<Driver>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "السائقين",
|
||||||
|
width: "2fr",
|
||||||
|
render: (d) => (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p>
|
||||||
|
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "branch", header: "الفرع", width: "1.2fr", render: (d) => d.branch?.name ?? "—" },
|
||||||
|
{ key: "nationality", header: "الجنسية", width: "1fr", render: (d) => d.nationality ?? "—" },
|
||||||
|
{
|
||||||
|
key: "licenseExpiry",
|
||||||
|
header: "تاريخ انتهاء الرخصة",
|
||||||
|
width: "1.1fr",
|
||||||
|
render: (d) => {
|
||||||
|
const warn = expirySoon(d.licenseExpiry);
|
||||||
|
return (
|
||||||
|
<span style={{ fontSize: 12, fontWeight: warn ? 600 : 400, color: warn ? "#D97706" : "var(--color-text-secondary)" }}>
|
||||||
|
{warn && "⚠ "}{fmtDate(d.licenseExpiry)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "nationalIdExpiry",
|
||||||
|
header: "تاريخ انتهاء الهوية الوطنية",
|
||||||
|
width: "1.1fr",
|
||||||
|
render: (d) => {
|
||||||
|
const idExpiry = (d as Driver & { nationalIdExpiry?: string }).nationalIdExpiry;
|
||||||
|
const warn = expirySoon(idExpiry);
|
||||||
|
return (
|
||||||
|
<span style={{ fontSize: 12, fontWeight: warn ? 600 : 400, color: warn ? "#D97706" : "var(--color-text-secondary)" }}>
|
||||||
|
{warn && "⚠ "}{fmtDate(idExpiry)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "الحالة",
|
||||||
|
width: "1fr",
|
||||||
|
render: (d) => {
|
||||||
|
const s = DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive;
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: "inline-flex", alignItems: "center", gap: 5,
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
border: `1px solid ${s.border}`,
|
||||||
|
background: s.bg,
|
||||||
|
padding: "0.2rem 0.625rem",
|
||||||
|
fontSize: 11, fontWeight: 600, color: s.color,
|
||||||
|
width: "fit-content",
|
||||||
|
}}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot, flexShrink: 0 }} />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReusableTable
|
||||||
|
columns={columns}
|
||||||
|
data={drivers}
|
||||||
|
loading={loading}
|
||||||
|
search={search}
|
||||||
|
page={page}
|
||||||
|
pages={pages}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
actionsWidth="0.8fr"
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
emptyDescription={search ? `No results for "${search}"` : undefined}
|
||||||
|
renderActions={(d) => (
|
||||||
|
<ActionButtons
|
||||||
|
itemLabel={d.name}
|
||||||
|
onEdit={() => onEdit(d)}
|
||||||
|
onDelete={() => onDelete(d)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,142 +1,86 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
|
||||||
|
|
||||||
|
import { ReusableTable, ActionButtons } from "../../UI";
|
||||||
import { DRIVER_STATUS_MAP } from "@/src/types/driver";
|
import { DRIVER_STATUS_MAP } from "@/src/types/driver";
|
||||||
|
import type { TableColumn } from "@/src/types/models";
|
||||||
import type { ArchivedDriver } from "@/src/types/driver";
|
import type { ArchivedDriver } from "@/src/types/driver";
|
||||||
|
|
||||||
// ── status badge ─────────────────────────────────────────────────────────────
|
|
||||||
// Kept custom rather than swapped for <Badge/>: colors come dynamically from
|
|
||||||
// DRIVER_STATUS_MAP (multiple statuses, each with its own color+dot), which
|
|
||||||
// the shared Badge component's fixed palette doesn't cover.
|
|
||||||
function StatusBadge({ status }: { status: ArchivedDriver["status"] }) {
|
|
||||||
const cfg = DRIVER_STATUS_MAP[status] ?? DRIVER_STATUS_MAP.Inactive;
|
|
||||||
return (
|
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${cfg.border}`, background: cfg.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: cfg.color }}>
|
|
||||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: cfg.dot }} />
|
|
||||||
{cfg.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── card / header styles ─────────────────────────────────────────────────────
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ROW_GRID_COLUMNS = "2fr 1.5fr 1fr 1fr 1fr 100px";
|
|
||||||
|
|
||||||
// ── Props ────────────────────────────────────────────────────────────────────
|
|
||||||
interface ArchivedDriverTableProps {
|
interface ArchivedDriverTableProps {
|
||||||
drivers: ArchivedDriver[];
|
drivers: ArchivedDriver[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
search: string;
|
search: string;
|
||||||
page: number;
|
page: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
onView: (driver: ArchivedDriver) => void;
|
onView: (driver: ArchivedDriver) => void;
|
||||||
onPageChange: (p: number) => void;
|
onPageChange: (p: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the paginated table of archived drivers, including loading,
|
|
||||||
* empty, and error-free states. Mirrors ArchivedUserTable for consistency.
|
|
||||||
*/
|
|
||||||
export function ArchivedDriverTable({ drivers, loading, search, page, pages, onView, onPageChange }: ArchivedDriverTableProps) {
|
export function ArchivedDriverTable({ drivers, loading, search, page, pages, onView, onPageChange }: ArchivedDriverTableProps) {
|
||||||
return (
|
const columns: TableColumn<ArchivedDriver>[] = [
|
||||||
<div style={cardStyle}>
|
{
|
||||||
{/* column headers */}
|
key: "name",
|
||||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, ...thStyle }}>
|
header: "الاسم",
|
||||||
<span>الاسم</span>
|
width: "2fr",
|
||||||
<span>اسم المستخدم</span>
|
render: (d) => (
|
||||||
<span>الحالة</span>
|
<div>
|
||||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p>
|
||||||
<span style={{ textAlign: "center" }}>آخر تحديث</span>
|
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
|
||||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* loading state */}
|
|
||||||
{loading ? (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : drivers.length === 0 ? (
|
),
|
||||||
/* empty state */
|
},
|
||||||
<EmptyState
|
{
|
||||||
icon="🗄️"
|
key: "userName",
|
||||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد سائقون في الأرشيف."}
|
header: "اسم السائق",
|
||||||
/>
|
width: "1.5fr",
|
||||||
) : (
|
render: (d) => (
|
||||||
/* data rows */
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{d.userName ?? "—"}</span>
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
),
|
||||||
{drivers.map((d, i) => (
|
},
|
||||||
<li key={d.id} onClick={() => onView(d)} style={{
|
{
|
||||||
display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS,
|
key: "status",
|
||||||
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
|
header: "الحالة",
|
||||||
borderBottom: "1px solid var(--color-border)",
|
width: "1fr",
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
render: (d) => {
|
||||||
fontSize: 13, cursor: "pointer",
|
const s = DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive;
|
||||||
}}>
|
return (
|
||||||
<div>
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: s.color }}>
|
||||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p>
|
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
|
||||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
|
{s.label}
|
||||||
</div>
|
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{d.userName ?? "—"}</span>
|
|
||||||
<StatusBadge status={d.status} />
|
|
||||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
|
||||||
{new Date(d.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
|
||||||
</span>
|
|
||||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
|
||||||
{new Date(d.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
|
||||||
{/* view only — delete/restore not implemented yet, same as users */}
|
|
||||||
<IconBtn title={`عرض ${d.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(d)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* pagination */}
|
|
||||||
{pages > 1 && (
|
|
||||||
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
);
|
||||||
<Button
|
},
|
||||||
type="button"
|
},
|
||||||
variant="secondary"
|
{
|
||||||
size="sm"
|
key: "createdAt",
|
||||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
header: "تاريخ الانشاء",
|
||||||
disabled={page === 1}
|
width: "1fr",
|
||||||
>
|
align: "center",
|
||||||
السابق
|
render: (d) => new Date(d.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||||
</Button>
|
},
|
||||||
<Button
|
{
|
||||||
type="button"
|
key: "updatedAt",
|
||||||
variant="secondary"
|
header: "تاريخ التعديل",
|
||||||
size="sm"
|
width: "1fr",
|
||||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
align: "center",
|
||||||
disabled={page === pages}
|
render: (d) => new Date(d.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||||
>
|
},
|
||||||
التالي
|
];
|
||||||
</Button>
|
|
||||||
</div>
|
return (
|
||||||
</div>
|
<ReusableTable
|
||||||
)}
|
columns={columns}
|
||||||
</div>
|
data={drivers}
|
||||||
|
loading={loading}
|
||||||
|
search={search}
|
||||||
|
page={page}
|
||||||
|
pages={pages}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onRowClick={onView}
|
||||||
|
emptyIcon="🗄️"
|
||||||
|
emptyDescription={search ? `No results for "${search}"` : "No archived drivers."}
|
||||||
|
renderActions={(d) => <ActionButtons itemLabel={d.name} onView={() => onView(d)} />}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -11,18 +11,7 @@ interface ArchivedDriversProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchIcon = (
|
const searchIcon = (
|
||||||
<svg
|
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
0
src/Components/Driver/driver.tsx
Normal file
0
src/Components/Driver/driver.tsx
Normal file
0
src/Components/Home/home.tsx
Normal file
0
src/Components/Home/home.tsx
Normal file
104
src/Components/Order/OrderDeleteModal.tsx
Normal file
104
src/Components/Order/OrderDeleteModal.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import type { Order } from "@/src/types/order";
|
||||||
|
|
||||||
|
interface OrderDeleteModalProps {
|
||||||
|
order: Order;
|
||||||
|
deleting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors DriverDeleteModal.tsx 1:1 — same alertdialog role, same
|
||||||
|
* backdrop/click-outside-to-close behavior, same Escape-to-close effect,
|
||||||
|
* same button layout. Only the copy and the identifying fields differ.
|
||||||
|
*/
|
||||||
|
export function OrderDeleteModal({ order, deleting, onCancel, onConfirm }: OrderDeleteModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alertdialog" aria-modal="true" aria-labelledby="order-del-title"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 60,
|
||||||
|
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%", maxWidth: 420,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
||||||
|
padding: "2rem",
|
||||||
|
display: "flex", flexDirection: "column", gap: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52, margin: "0 auto", borderRadius: "50%",
|
||||||
|
background: "#FEF2F2", border: "1px solid #FECACA",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 id="order-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
|
حذف الطلب
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
||||||
|
هل أنت متأكد من حذف الطلب{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>{order.shipmentNumber}</strong>
|
||||||
|
{" "}الخاص بـ{" "}
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||||
|
{order.recipientName}
|
||||||
|
</span>
|
||||||
|
؟ لا يمكن التراجع عن هذا الإجراء.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button type="button" onClick={onCancel} disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)", background: "var(--color-surface)",
|
||||||
|
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
|
||||||
|
cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)",
|
||||||
|
}}>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onConfirm} disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
||||||
|
border: "none", background: "#DC2626",
|
||||||
|
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||||
|
cursor: deleting ? "not-allowed" : "pointer",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1,
|
||||||
|
}}>
|
||||||
|
{deleting && <Spinner size="sm" className="text-white" />}
|
||||||
|
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
551
src/Components/Order/OrderDetailBanel.tsx
Normal file
551
src/Components/Order/OrderDetailBanel.tsx
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Spinner, Alert } from "../UI";
|
||||||
|
import { orderService } from "@/src/services/order.service";
|
||||||
|
import type { Order, OrderStatus, UpdateOrderStatusPayload } from "@/src/types/order";
|
||||||
|
|
||||||
|
const ORDER_STATUS_MAP: Record<OrderStatus, { label: string; color: string; bg: string; border: string; dot: string }> = {
|
||||||
|
Created: { label: "تم الإنشاء", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE", dot: "#3B82F6" },
|
||||||
|
Assigned: { label: "مُعيَّن", color: "#5B21B6", bg: "#F5F3FF", border: "#DDD6FE", dot: "#8B5CF6" },
|
||||||
|
InTransit: { label: "قيد التوصيل", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" },
|
||||||
|
Delivered: { label: "تم التسليم", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" },
|
||||||
|
Returned: { label: "مُرتجع", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA", dot: "#DC2626" },
|
||||||
|
Cancelled: { label: "ملغي", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0", dot: "#94A3B8" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAY_STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
Pending: { label: "معلَّق", color: "#D97706" },
|
||||||
|
Paid: { label: "مدفوع", color: "#16A34A" },
|
||||||
|
Failed: { label: "فشل", color: "#DC2626" },
|
||||||
|
Refunded: { label: "مُسترجع", color: "#64748B" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAY_METHOD_LABEL: Record<string, string> = {
|
||||||
|
Cash: "نقداً",
|
||||||
|
Card: "بطاقة",
|
||||||
|
Prepaid: "مدفوع مسبقاً",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtDate(iso?: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAmount(n?: number | string | null): string {
|
||||||
|
if (n == null || n === "") return "—";
|
||||||
|
const num = Number(n);
|
||||||
|
if (isNaN(num)) return "—";
|
||||||
|
return `${num.toFixed(2)} ر.س`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono = false,
|
||||||
|
warn = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
warn?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline",
|
||||||
|
padding: "0.55rem 0",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||||
|
color: warn ? "#D97706" : "var(--color-text-primary)",
|
||||||
|
fontWeight: warn ? 600 : 400,
|
||||||
|
maxWidth: "60%",
|
||||||
|
textAlign: "left",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{warn && value !== "—" ? "⚠ " : ""}
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeading({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.25em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-text-hint)",
|
||||||
|
fontWeight: 700,
|
||||||
|
margin: "1.25rem 0 0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderDetailPanelProps {
|
||||||
|
orderId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: (order: Order) => void;
|
||||||
|
onDelete: (order: Order) => void;
|
||||||
|
onStatusChanged?: (order: Order) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderDetailPanel({
|
||||||
|
orderId,
|
||||||
|
onClose,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onStatusChanged,
|
||||||
|
}: OrderDetailPanelProps) {
|
||||||
|
const [order, setOrder] = useState<Order | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [statusDraft, setStatusDraft] = useState<OrderStatus | "">("");
|
||||||
|
const [statusReason, setStatusReason] = useState("");
|
||||||
|
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||||
|
const [statusError, setStatusError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadOrder = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await orderService.getById(orderId);
|
||||||
|
setOrder(data);
|
||||||
|
setStatusDraft(data.currentStatus);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات الطلب.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [orderId]);
|
||||||
|
|
||||||
|
useEffect(() => { queueMicrotask(loadOrder); }, [loadOrder]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleStatusUpdate = useCallback(async () => {
|
||||||
|
if (!order || !statusDraft || statusDraft === order.currentStatus) return;
|
||||||
|
setUpdatingStatus(true);
|
||||||
|
setStatusError(null);
|
||||||
|
try {
|
||||||
|
const payload: UpdateOrderStatusPayload = { status: statusDraft };
|
||||||
|
if (statusReason) payload.reason = statusReason;
|
||||||
|
const updated = await orderService.updateStatus(order.id, payload);
|
||||||
|
setOrder(updated);
|
||||||
|
setStatusReason("");
|
||||||
|
onStatusChanged?.(updated);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setStatusError(err instanceof Error ? err.message : "تعذّر تحديث حالة الطلب.");
|
||||||
|
} finally {
|
||||||
|
setUpdatingStatus(false);
|
||||||
|
}
|
||||||
|
}, [order, statusDraft, statusReason, onStatusChanged]);
|
||||||
|
|
||||||
|
const statusConfig = order ? ORDER_STATUS_MAP[order.currentStatus] : null;
|
||||||
|
const payStatus = order?.paymentStatus ? PAY_STATUS_MAP[order.paymentStatus] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 40,
|
||||||
|
background: "rgba(15,23,42,0.45)",
|
||||||
|
backdropFilter: "blur(2px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
aria-label="تفاصيل الطلب"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
width: "min(520px, 100vw)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRight: "1px solid var(--color-border)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
boxShadow: "8px 0 40px rgba(0,0,0,.18)",
|
||||||
|
overflowY: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1.25rem 1.5rem",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: "50%",
|
||||||
|
flexShrink: 0,
|
||||||
|
border: "2px solid var(--color-brand-200)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-brand-600)" strokeWidth="2">
|
||||||
|
<rect x="2" y="7" width="20" height="14" rx="2"/>
|
||||||
|
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
||||||
|
ملف الطلب
|
||||||
|
</p>
|
||||||
|
{order && (
|
||||||
|
<h2 style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "3px 0 0", fontFamily: "var(--font-mono)" }}>
|
||||||
|
{order.shipmentNumber}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{order?.trip?.tripNumber && (
|
||||||
|
<p style={{ fontSize: 11, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)", margin: "2px 0 0" }}>
|
||||||
|
رحلة: {order.trip.tripNumber}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="إغلاق"
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 18,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "1.25rem 1.5rem" }}>
|
||||||
|
{loading && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
|
||||||
|
<Spinner size="sm" className="text-blue-600" />
|
||||||
|
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>جارٍ التحميل…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
background: "#FEF2F2",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#DC2626",
|
||||||
|
}}>
|
||||||
|
⚠ {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order && !loading && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1rem" }}>
|
||||||
|
{statusConfig && (
|
||||||
|
<span style={{
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
border: `1px solid ${statusConfig.border}`,
|
||||||
|
background: statusConfig.bg,
|
||||||
|
padding: "0.3rem 0.875rem",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: statusConfig.color,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: statusConfig.dot }} />
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{payStatus && (
|
||||||
|
<span style={{
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
padding: "0.3rem 0.875rem",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: payStatus.color,
|
||||||
|
}}>
|
||||||
|
{payStatus.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeading title="بيانات المستلم" />
|
||||||
|
<DetailRow label="اسم المستلم" value={order.recipientName} />
|
||||||
|
<DetailRow label="رقم الجوال" value={order.recipientPhone} mono />
|
||||||
|
<DetailRow label="العميل" value={order.client?.name ?? "—"} />
|
||||||
|
|
||||||
|
<SectionHeading title="بيانات الشحنة" />
|
||||||
|
<DetailRow label="رقم الشحنة" value={order.shipmentNumber} mono />
|
||||||
|
<DetailRow label="نوع الشحنة" value={order.type ?? "—"} />
|
||||||
|
<DetailRow label="الكمية" value={String(order.quantity ?? "—")} />
|
||||||
|
<DetailRow label="الوزن" value={order.weight != null ? `${order.weight} كجم` : "—"} />
|
||||||
|
<DetailRow label="الرحلة" value={order.trip?.tripNumber ?? "—"} mono />
|
||||||
|
|
||||||
|
<SectionHeading title="بيانات الدفع" />
|
||||||
|
<DetailRow label="طريقة الدفع" value={order.paymentMethod ? PAY_METHOD_LABEL[order.paymentMethod] ?? order.paymentMethod : "—"} />
|
||||||
|
<DetailRow label="حالة الدفع" value={payStatus?.label ?? "—"} />
|
||||||
|
<DetailRow label="الإجمالي الفرعي" value={fmtAmount(order.subTotal)} mono />
|
||||||
|
<DetailRow label="ضريبة القيمة المضافة" value={fmtAmount(order.vatAmount)} mono />
|
||||||
|
<DetailRow label="الإجمالي" value={fmtAmount(order.totalPrice)} mono />
|
||||||
|
|
||||||
|
{order.deliveryAddress?.details && Object.values(order.deliveryAddress.details).some(Boolean) && (
|
||||||
|
<>
|
||||||
|
<SectionHeading title="عنوان التسليم" />
|
||||||
|
{order.deliveryAddress.details.city && <DetailRow label="المدينة" value={order.deliveryAddress.details.city} />}
|
||||||
|
{order.deliveryAddress.details.district && <DetailRow label="الحي" value={order.deliveryAddress.details.district} />}
|
||||||
|
{order.deliveryAddress.details.street && <DetailRow label="الشارع" value={order.deliveryAddress.details.street} />}
|
||||||
|
{order.deliveryAddress.details.buildingNo && <DetailRow label="رقم المبنى" value={order.deliveryAddress.details.buildingNo} mono />}
|
||||||
|
{order.deliveryAddress.details.unitNo && <DetailRow label="رقم الوحدة" value={order.deliveryAddress.details.unitNo} mono />}
|
||||||
|
{order.deliveryAddress.details.zipCode && <DetailRow label="الرمز البريدي" value={order.deliveryAddress.details.zipCode} mono />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!order.deliveryAddress?.details && order.deliveryAddressId && (
|
||||||
|
<>
|
||||||
|
<SectionHeading title="عنوان التسليم" />
|
||||||
|
<DetailRow label="معرّف العنوان" value={order.deliveryAddressId} mono />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order.pickupAddress?.details && Object.values(order.pickupAddress.details).some(Boolean) && (
|
||||||
|
<>
|
||||||
|
<SectionHeading title="عنوان الاستلام" />
|
||||||
|
{order.pickupAddress.details.city && <DetailRow label="المدينة" value={order.pickupAddress.details.city} />}
|
||||||
|
{order.pickupAddress.details.district && <DetailRow label="الحي" value={order.pickupAddress.details.district} />}
|
||||||
|
{order.pickupAddress.details.street && <DetailRow label="الشارع" value={order.pickupAddress.details.street} />}
|
||||||
|
{order.pickupAddress.details.buildingNo && <DetailRow label="رقم المبنى" value={order.pickupAddress.details.buildingNo} mono />}
|
||||||
|
{order.pickupAddress.details.unitNo && <DetailRow label="رقم الوحدة" value={order.pickupAddress.details.unitNo} mono />}
|
||||||
|
{order.pickupAddress.details.zipCode && <DetailRow label="الرمز البريدي" value={order.pickupAddress.details.zipCode} mono />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!order.pickupAddress?.details && order.pickupAddressId && (
|
||||||
|
<>
|
||||||
|
<SectionHeading title="عنوان الاستلام" />
|
||||||
|
<DetailRow label="معرّف العنوان" value={order.pickupAddressId} mono />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionHeading title="معلومات النظام" /> <DetailRow label="تاريخ الإنشاء" value={fmtDate(order.createdAt)} />
|
||||||
|
<DetailRow label="آخر تحديث" value={fmtDate(order.updatedAt)} />
|
||||||
|
|
||||||
|
<SectionHeading title="تحديث الحالة" />
|
||||||
|
{statusError && (
|
||||||
|
<Alert type="error" message={statusError} onClose={() => setStatusError(null)} />
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
|
||||||
|
<select
|
||||||
|
value={statusDraft}
|
||||||
|
onChange={(e) => setStatusDraft(e.target.value as OrderStatus)}
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
|
height: 40, padding: "0 0.75rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13, color: "var(--color-text-primary)",
|
||||||
|
fontFamily: "var(--font-sans)", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(ORDER_STATUS_MAP).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{v.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={statusReason}
|
||||||
|
onChange={(e) => setStatusReason(e.target.value)}
|
||||||
|
placeholder="سبب التغيير (اختياري)"
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
|
height: 40, padding: "0 0.75rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13, color: "var(--color-text-primary)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleStatusUpdate}
|
||||||
|
disabled={updatingStatus || !statusDraft || statusDraft === order.currentStatus}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "none",
|
||||||
|
background: updatingStatus ? "var(--color-brand-400)" : "var(--color-brand-600)",
|
||||||
|
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||||
|
cursor: (updatingStatus || !statusDraft || statusDraft === order.currentStatus) ? "not-allowed" : "pointer",
|
||||||
|
opacity: (!statusDraft || statusDraft === order.currentStatus) ? 0.6 : 1,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{updatingStatus && <Spinner size="sm" className="text-white" />}
|
||||||
|
{updatingStatus ? "جارٍ التحديث…" : "تحديث الحالة"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order.statusHistory && order.statusHistory.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionHeading title="سجل الحالات" />
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{order.statusHistory.slice(0, 5).map((h) => {
|
||||||
|
const s = ORDER_STATUS_MAP[h.status] ?? ORDER_STATUS_MAP.Created;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={h.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: `1px solid ${s.border}`,
|
||||||
|
background: s.bg,
|
||||||
|
padding: "0.5rem 0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>{s.label}</span>
|
||||||
|
{h.reason && (
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}>
|
||||||
|
— {h.reason}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
{fmtDate(h.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem 1.5rem",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.75rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(order)}
|
||||||
|
style={{
|
||||||
|
height: 40, padding: "0 1rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
background: "#FEF2F2",
|
||||||
|
fontSize: 13, fontWeight: 700, color: "#DC2626",
|
||||||
|
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEdit(order)}
|
||||||
|
style={{
|
||||||
|
height: 40, padding: "0 1rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-brand-200)",
|
||||||
|
background: "var(--color-brand-50, #EFF6FF)",
|
||||||
|
fontSize: 13, fontWeight: 700, color: "var(--color-brand-600)",
|
||||||
|
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||||
|
display: "flex", alignItems: "center", gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
تعديل
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
flex: 1, height: 40,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
|
||||||
|
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إغلاق
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ORDER_STATUS_MAP, PAY_STATUS_MAP, PAY_METHOD_LABEL };
|
||||||
@@ -222,10 +222,7 @@ export function OrderDetailPanel({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-brand-600)" strokeWidth="2">
|
<i className="ti ti-package" style={{ fontSize: 24, color: "var(--color-brand-600)" }} aria-hidden="true" />
|
||||||
<rect x="2" y="7" width="20" height="14" rx="2"/>
|
|
||||||
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -460,10 +457,7 @@ export function OrderDetailPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" variant="secondary" onClick={() => onEdit(order)}>
|
<Button type="button" variant="secondary" onClick={() => onEdit(order)}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
تعديل
|
تعديل
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -372,11 +372,8 @@ export function OrderFormModal({
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
|
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
إضافة عميل جديد
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
إنشاء عميل جديد
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -418,10 +415,7 @@ export function OrderFormModal({
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Link href="/dashboard/trips" style={createLinkStyle} tabIndex={-1}>
|
<Link href="/dashboard/trips" style={createLinkStyle} tabIndex={-1}>
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
إنشاء رحلة جديدة
|
إنشاء رحلة جديدة
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,60 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner } from "../UI";
|
import { ReusableTable, ActionButtons } from "../UI";
|
||||||
import type { Order } from "@/src/types/order";
|
|
||||||
import { ORDER_STATUS_MAP } from "./OrderDetailModel";
|
import { ORDER_STATUS_MAP } from "./OrderDetailModel";
|
||||||
|
import type { TableColumn } from "@/src/types/models";
|
||||||
// ── Helpers — copied verbatim from app/dashboard/drivers/page.tsx ───────
|
import type { Order } from "@/src/types/order";
|
||||||
|
|
||||||
function fmtDate(iso?: string | null): string {
|
function fmtDate(iso?: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||||
year: "numeric", month: "short", day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtAmount(n?: number | null): string {
|
function fmtAmount(n?: number | null): string {
|
||||||
if (n == null) return "—";
|
if (n == null) return "—";
|
||||||
return `${n.toFixed(2)} ر.س`;
|
return `${n.toFixed(2)} SAR`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Styles — identical tokens/values to the Drivers page, per the
|
|
||||||
// "no new styles" constraint; only column widths differ to fit Order's
|
|
||||||
// field set (shipment / recipient / client / amount / status / actions).
|
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
overflow: "hidden",
|
|
||||||
boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Shared across header + rows — keep these in sync or columns will misalign.
|
|
||||||
const ROW_GRID_COLUMNS = "1.6fr 1.6fr 1.2fr 1.1fr 1fr 0.8fr";
|
|
||||||
|
|
||||||
const iconBtnBase: React.CSSProperties = {
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
cursor: "pointer",
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface OrderTableProps {
|
interface OrderTableProps {
|
||||||
orders: Order[];
|
orders: Order[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -68,181 +28,86 @@ interface OrderTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OrderTable({
|
export function OrderTable({
|
||||||
orders,
|
orders, loading, search, page, pages,
|
||||||
loading,
|
setPage, onRowClick, onEdit, onDelete,
|
||||||
search,
|
|
||||||
page,
|
|
||||||
pages,
|
|
||||||
setPage,
|
|
||||||
onRowClick,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}: OrderTableProps) {
|
}: OrderTableProps) {
|
||||||
return (
|
const columns: TableColumn<Order>[] = [
|
||||||
<div style={cardStyle}>
|
{
|
||||||
<div
|
key: "shipmentNumber",
|
||||||
dir="rtl"
|
header: "رقم الشحنة #",
|
||||||
style={{
|
width: "1.6fr",
|
||||||
display: "grid",
|
render: (o) => (
|
||||||
gridTemplateColumns: ROW_GRID_COLUMNS,
|
<div>
|
||||||
...thStyle,
|
<p style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB", margin: 0 }}>{o.shipmentNumber}</p>
|
||||||
}}
|
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(o.createdAt)}</p>
|
||||||
>
|
|
||||||
<span>رقم الشحنة</span>
|
|
||||||
<span>المستلم</span>
|
|
||||||
<span>العميل</span>
|
|
||||||
<span>المبلغ</span>
|
|
||||||
<span>الحالة</span>
|
|
||||||
<span>إجراءات</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : orders.length === 0 ? (
|
),
|
||||||
<p style={{ textAlign: "center", padding: "4rem 0", fontSize: 13, color: "var(--color-text-muted)" }}>
|
},
|
||||||
لا توجد نتائج {search && `لـ "${search}"`}
|
{
|
||||||
</p>
|
key: "recipient",
|
||||||
) : (
|
header: "المستلم",
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
width: "1.6fr",
|
||||||
{orders.map((o, i) => {
|
render: (o) => (
|
||||||
const statusCfg = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
|
||||||
return (
|
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
|
||||||
<li
|
</div>
|
||||||
key={o.id}
|
),
|
||||||
onClick={() => onRowClick(o.id)}
|
},
|
||||||
style={{
|
{ key: "client", header: "العميل", width: "1.2fr", render: (o) => o.clientId ?? "—" },
|
||||||
display: "grid",
|
{
|
||||||
gridTemplateColumns: ROW_GRID_COLUMNS,
|
key: "amount",
|
||||||
alignItems: "center", gap: "0.5rem",
|
header: "الكمية",
|
||||||
padding: "0.875rem 1.5rem",
|
width: "1.1fr",
|
||||||
borderBottom: "1px solid var(--color-border)",
|
render: (o) => (
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
<span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}>
|
||||||
fontSize: 13,
|
{o.quantity ?? "—"}
|
||||||
cursor: "pointer",
|
</span>
|
||||||
transition: "background 0.15s",
|
),
|
||||||
}}
|
},
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--color-surface-hover, #F8FAFC)")}
|
{
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent")}
|
key: "status",
|
||||||
>
|
header: "الحالة",
|
||||||
<div>
|
width: "1fr",
|
||||||
<p style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB", margin: 0 }}>{o.shipmentNumber}</p>
|
render: (o) => {
|
||||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(o.createdAt)}</p>
|
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
|
||||||
</div>
|
return (
|
||||||
<div>
|
<span style={{
|
||||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
|
display: "inline-flex", alignItems: "center", gap: 5,
|
||||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
|
borderRadius: "var(--radius-full)",
|
||||||
</div>
|
border: `1px solid ${s.border}`,
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>{o.client?.name ?? "—"}</span>
|
background: s.bg,
|
||||||
<span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}>
|
padding: "0.2rem 0.625rem",
|
||||||
{fmtAmount(o.totalPrice)}
|
fontSize: 11, fontWeight: 600, color: s.color,
|
||||||
</span>
|
width: "fit-content",
|
||||||
<span style={{
|
}}>
|
||||||
display: "inline-flex", alignItems: "center", gap: 5,
|
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot, flexShrink: 0 }} />
|
||||||
borderRadius: "var(--radius-full)",
|
{s.label}
|
||||||
border: `1px solid ${statusCfg.border}`,
|
|
||||||
background: statusCfg.bg,
|
|
||||||
padding: "0.2rem 0.625rem",
|
|
||||||
fontSize: 11, fontWeight: 600, color: statusCfg.color,
|
|
||||||
width: "fit-content",
|
|
||||||
}}>
|
|
||||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusCfg.dot, flexShrink: 0 }} />
|
|
||||||
{statusCfg.label}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* ── Inline row actions: edit / delete ──
|
|
||||||
stopPropagation is required on both buttons so the
|
|
||||||
click doesn't bubble up to the <li> onClick and
|
|
||||||
open the detail panel as well. */}
|
|
||||||
<div style={{ display: "flex", gap: "0.4rem" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="تعديل الطلب"
|
|
||||||
title="تعديل"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onEdit(o); }}
|
|
||||||
style={{
|
|
||||||
...iconBtnBase,
|
|
||||||
border: "1px solid var(--color-brand-200)",
|
|
||||||
background: "var(--color-brand-50, #EFF6FF)",
|
|
||||||
color: "var(--color-brand-600)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="حذف الطلب"
|
|
||||||
title="حذف"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete(o); }}
|
|
||||||
style={{
|
|
||||||
...iconBtnBase,
|
|
||||||
border: "1px solid #FECACA",
|
|
||||||
background: "#FEF2F2",
|
|
||||||
color: "#DC2626",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" />
|
|
||||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Pagination ── */}
|
|
||||||
{pages > 1 && (
|
|
||||||
<div
|
|
||||||
dir="rtl"
|
|
||||||
style={{
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
||||||
borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
صفحة{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>{page}</strong>{" "}
|
|
||||||
من{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
);
|
||||||
{[
|
},
|
||||||
{ label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
|
},
|
||||||
{ label: "التالي", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
|
];
|
||||||
].map(btn => (
|
|
||||||
<button
|
return (
|
||||||
key={btn.label}
|
<ReusableTable
|
||||||
onClick={btn.action}
|
columns={columns}
|
||||||
disabled={btn.disabled}
|
data={orders}
|
||||||
style={{
|
loading={loading}
|
||||||
borderRadius: "var(--radius-lg)",
|
search={search}
|
||||||
border: "1px solid var(--color-border)",
|
page={page}
|
||||||
background: "var(--color-surface-muted)",
|
pages={pages}
|
||||||
padding: "0.375rem 0.875rem",
|
onPageChange={setPage}
|
||||||
fontSize: 12, color: "var(--color-text-secondary)",
|
actionsWidth="0.8fr"
|
||||||
cursor: btn.disabled ? "not-allowed" : "pointer",
|
onRowClick={(o) => onRowClick(o.id)}
|
||||||
opacity: btn.disabled ? 0.4 : 1,
|
emptyDescription={search ? `No results for "${search}"` : undefined}
|
||||||
fontFamily: "var(--font-sans)",
|
renderActions={(o) => (
|
||||||
}}
|
<ActionButtons
|
||||||
>
|
itemLabel={o.shipmentNumber}
|
||||||
{btn.label}
|
onEdit={() => onEdit(o)}
|
||||||
</button>
|
onDelete={() => onDelete(o)}
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,132 +1,80 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
import { ReusableTable, ActionButtons } from "../../UI";
|
||||||
import { ORDER_STATUS_MAP } from "../OrderDetailModel";
|
import { ORDER_STATUS_MAP } from "../OrderDetailModel";
|
||||||
|
import type { TableColumn } from "@/src/types/models";
|
||||||
import type { ArchivedOrder } from "@/src/types/order";
|
import type { ArchivedOrder } from "@/src/types/order";
|
||||||
|
|
||||||
|
|
||||||
function fmtAmount(n?: string | number | null): string {
|
function fmtAmount(n?: string | number | null): string {
|
||||||
if (n == null) return "—";
|
if (n == null) return "—";
|
||||||
const num = typeof n === "string" ? parseFloat(n) : n;
|
const num = typeof n === "string" ? parseFloat(n) : n;
|
||||||
if (Number.isNaN(num)) return "—";
|
if (Number.isNaN(num)) return "—";
|
||||||
return `${num.toFixed(2)} ر.س`;
|
return `${num.toFixed(2)} SAR`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
const ROW_GRID = "1.6fr 1.6fr 1fr 1fr 100px";
|
|
||||||
|
|
||||||
interface ArchivedOrderTableProps {
|
interface ArchivedOrderTableProps {
|
||||||
orders: ArchivedOrder[];
|
orders: ArchivedOrder[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
search: string;
|
search: string;
|
||||||
page: number;
|
page: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
onView: (order: ArchivedOrder) => void;
|
onView: (order: ArchivedOrder) => void;
|
||||||
onPageChange: (p: number) => void;
|
onPageChange: (p: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArchivedOrderTable({ orders, loading, search, page, pages, onView, onPageChange }: ArchivedOrderTableProps) {
|
export function ArchivedOrderTable({ orders, loading, search, page, pages, onView, onPageChange }: ArchivedOrderTableProps) {
|
||||||
return (
|
const columns: TableColumn<ArchivedOrder>[] = [
|
||||||
<div style={cardStyle}>
|
{
|
||||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID, ...thStyle }}>
|
key: "shipmentNumber",
|
||||||
<span>رقم الشحنة</span>
|
header: "رقم الشحنة #",
|
||||||
<span>المستلم</span>
|
width: "1.6fr",
|
||||||
<span>المبلغ</span>
|
render: (o) => <span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB" }}>{o.shipmentNumber}</span>,
|
||||||
<span>الحالة</span>
|
},
|
||||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
{
|
||||||
</div>
|
key: "recipient",
|
||||||
|
header: "المستلم",
|
||||||
{loading ? (
|
width: "1.6fr",
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
render: (o) => (
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
<div>
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
|
||||||
|
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
|
||||||
</div>
|
</div>
|
||||||
) : orders.length === 0 ? (
|
),
|
||||||
<EmptyState
|
},
|
||||||
icon="🗄️"
|
{
|
||||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد طلبات في الأرشيف."}
|
key: "amount",
|
||||||
/>
|
header: "الكمية",
|
||||||
) : (
|
width: "1fr",
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
render: (o) => <span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "var(--color-text-primary)" }}>{fmtAmount(o.subTotal)}</span>,
|
||||||
{orders.map((o, i) => {
|
},
|
||||||
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
|
{
|
||||||
return (
|
key: "status",
|
||||||
<li key={o.id} style={{
|
header: "الحالة",
|
||||||
display: "grid", gridTemplateColumns: ROW_GRID,
|
width: "1fr",
|
||||||
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
|
render: (o) => {
|
||||||
borderBottom: "1px solid var(--color-border)",
|
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
return (
|
||||||
fontSize: 13,
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: s.color, width: "fit-content" }}>
|
||||||
}}>
|
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
|
||||||
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB" }}>
|
{s.label}
|
||||||
{o.shipmentNumber}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
|
|
||||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "var(--color-text-primary)" }}>
|
|
||||||
{fmtAmount(o.subTotal)}
|
|
||||||
</span>
|
|
||||||
<span style={{
|
|
||||||
display: "inline-flex", alignItems: "center", gap: 5,
|
|
||||||
borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`,
|
|
||||||
background: s.bg, padding: "0.2rem 0.625rem",
|
|
||||||
fontSize: 11, fontWeight: 600, color: s.color, width: "fit-content",
|
|
||||||
}}>
|
|
||||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
|
|
||||||
{s.label}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
|
||||||
<IconBtn title={`عرض ${o.shipmentNumber}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(o)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pages > 1 && (
|
|
||||||
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
);
|
||||||
<Button
|
},
|
||||||
type="button"
|
},
|
||||||
variant="secondary"
|
];
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
return (
|
||||||
disabled={page === 1}
|
<ReusableTable
|
||||||
>
|
columns={columns}
|
||||||
السابق
|
data={orders}
|
||||||
</Button>
|
loading={loading}
|
||||||
<Button
|
search={search}
|
||||||
type="button"
|
page={page}
|
||||||
variant="secondary"
|
pages={pages}
|
||||||
size="sm"
|
onPageChange={onPageChange}
|
||||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
emptyIcon="🗄️"
|
||||||
disabled={page === pages}
|
emptyDescription={search ? `No results for "${search}"` : "No archived orders."}
|
||||||
>
|
renderActions={(o) => <ActionButtons itemLabel={o.shipmentNumber} onView={() => onView(o)} />}
|
||||||
التالي
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,18 +12,7 @@ interface ArchivedOrdersModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchIcon = (
|
const searchIcon = (
|
||||||
<svg
|
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
|
export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
|
||||||
|
|||||||
210
src/Components/Order/order.tsx
Normal file
210
src/Components/Order/order.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { type FC } from "react";
|
||||||
|
import { useClientOrders } from "@/src/hooks/useClientOrders";
|
||||||
|
import { fmtDate, fmtAmount } from "@/src/lib/formatters";
|
||||||
|
import { statusColor, statusLabel } from "@/src/lib/order-status";
|
||||||
|
import { clearSession, type ClientSession } from "@/src/lib/session";
|
||||||
|
import type { OrderStatus } from "@/src/types/order";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
|
||||||
|
// ─── Order status tracker ─────────────────────
|
||||||
|
const STATUS_STEPS: OrderStatus[] = ["CREATED", "IN_TRANSIT", "DELIVERED"];
|
||||||
|
const STEP_LABELS = ["تم الإنشاء", "قيد التوصيل", "تم التسليم"];
|
||||||
|
|
||||||
|
const StepIcon: FC<{ step: number }> = ({ step }) => {
|
||||||
|
if (step === 0) return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1"/>
|
||||||
|
<path d="M4 7h16v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
if (step === 1) return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="2" y="7" width="20" height="14" rx="2"/>
|
||||||
|
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
|
||||||
|
<line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrderTracker: FC<{ status: OrderStatus }> = ({ status }) => {
|
||||||
|
const idx = STATUS_STEPS.indexOf(status === "CANCELLED" ? "CREATED" : status);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0 py-2" dir="rtl" role="list" aria-label="تقدم الطلب">
|
||||||
|
{STATUS_STEPS.map((s, i) => (
|
||||||
|
<React.Fragment key={s}>
|
||||||
|
<div className="flex flex-col items-center gap-1" role="listitem">
|
||||||
|
<div className={`w-9 h-9 rounded-full flex items-center justify-center transition-all ${
|
||||||
|
status === "CANCELLED"
|
||||||
|
? "bg-[#FEE2E2] text-red-500 border-2 border-red-200"
|
||||||
|
: i <= idx ? "bg-[#1A73E8] text-white" : "bg-white border-2 border-[#E5E7EB] text-[#D1D5DB]"
|
||||||
|
}`} aria-current={i === idx ? "step" : undefined}>
|
||||||
|
<StepIcon step={i} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-[10px] font-semibold whitespace-nowrap ${
|
||||||
|
status === "CANCELLED" ? "text-red-400" : i <= idx ? "text-[#1A73E8]" : "text-[#9CA3AF]"
|
||||||
|
}`}>
|
||||||
|
{STEP_LABELS[i]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{i < STATUS_STEPS.length - 1 && (
|
||||||
|
<div aria-hidden="true" className={`flex-1 h-0.5 mb-4 mx-1 ${
|
||||||
|
status === "CANCELLED" ? "bg-red-200" : i < idx ? "bg-[#1A73E8]" : "bg-[#E5E7EB]"
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── DashboardStep ───────────────────────────
|
||||||
|
interface DashboardStepProps {
|
||||||
|
session: ClientSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
|
||||||
|
const { orders, loading, error } = useClientOrders(session.id);
|
||||||
|
|
||||||
|
const activeOrder = orders.find(o => o.status === "CREATED" || o.status === "IN_TRANSIT");
|
||||||
|
const pastOrders = orders.filter(o => o !== activeOrder);
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
total: orders.length,
|
||||||
|
delivered: orders.filter(o => o.status === "DELIVERED").length,
|
||||||
|
pending: orders.filter(o => o.status === "CREATED" || o.status === "IN_TRANSIT").length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div dir="rtl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight">لوحة الطلبات</h2>
|
||||||
|
<p className="text-[13px] text-[#6B7280] mt-0.5">
|
||||||
|
مرحبًا بعودتك، <strong className="text-[#111827]">{session.name}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 bg-[#D1FAE5] border border-[#A7F3D0] rounded-full px-3 py-1.5" role="status" aria-label="الحساب نشط">
|
||||||
|
<span aria-hidden="true" className="w-2 h-2 rounded-full bg-[#10B981] motion-safe:animate-pulse" />
|
||||||
|
<span className="text-[11px] font-bold text-[#065F46]">نشط</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-14 gap-3" role="status">
|
||||||
|
<Spinner />
|
||||||
|
<p className="text-[13px] text-[#6B7280]">جارٍ تحميل الطلبات…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-10" role="alert">
|
||||||
|
<p className="text-[13px] text-red-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||||
|
{[
|
||||||
|
{ label: "إجمالي الطلبات", value: summary.total, color: "text-[#111827]" },
|
||||||
|
{ label: "مُسلَّمة", value: summary.delivered, color: "text-[#34A853]" },
|
||||||
|
{ label: "قيد التنفيذ", value: summary.pending, color: "text-[#1A73E8]" },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} className="bg-white border border-[#E5E7EB] rounded-xl p-3.5">
|
||||||
|
<p className="text-[10px] font-bold text-[#9CA3AF] mb-1">{s.label}</p>
|
||||||
|
<p className={`text-[26px] font-bold ${s.color}`}>{s.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active order tracker */}
|
||||||
|
{activeOrder ? (
|
||||||
|
<div className="bg-gradient-to-bl from-[#0D47A1] to-[#1A73E8] rounded-xl p-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-bold text-white/60 tracking-wide">الطلب الحالي</p>
|
||||||
|
<p className="text-[14px] font-bold text-white font-mono">{activeOrder.id}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[11px] font-bold border px-2.5 py-1 rounded-full ${
|
||||||
|
activeOrder.status === "CREATED"
|
||||||
|
? "bg-white/15 border-white/30 text-white"
|
||||||
|
: "bg-[#D1FAE5] border-[#A7F3D0] text-[#065F46]"
|
||||||
|
}`}>
|
||||||
|
{statusLabel(activeOrder.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<OrderTracker status={activeOrder.status} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-dashed border-[#E5E7EB] rounded-xl p-5 mb-6 text-center">
|
||||||
|
<p className="text-[13px] text-[#9CA3AF]">لا يوجد طلب نشط حاليًا.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Past orders table */}
|
||||||
|
{pastOrders.length > 0 && (
|
||||||
|
<div className="bg-white border border-[#E5E7EB] rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-[#E5E7EB] flex items-center justify-between">
|
||||||
|
<p className="text-[13px] font-bold text-[#111827]">سجل الطلبات</p>
|
||||||
|
<span className="text-[11px] text-[#9CA3AF] font-medium">{pastOrders.length} طلبات</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full" dir="rtl">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#F9FAFB]">
|
||||||
|
{["رقم الطلب", "التاريخ", "الوصف", "الحالة", "المبلغ"].map(h => (
|
||||||
|
<th key={h} scope="col" className="px-4 py-2.5 text-right text-[10px] font-bold text-[#9CA3AF] uppercase tracking-wide">
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pastOrders.map((order, i) => (
|
||||||
|
<tr key={order.id} className={`border-t border-[#F3F4F6] hover:bg-[#FAFAFA] transition-colors ${i % 2 === 0 ? "" : "bg-[#FAFAFA]/30"}`}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="font-mono text-[12px] font-semibold text-[#1A73E8]">{order.id}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[12px] text-[#6B7280] whitespace-nowrap">{fmtDate(order.createdAt)}</td>
|
||||||
|
<td className="px-4 py-3 text-[12px] text-[#374151]">{order.description}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-[11px] font-bold border px-2.5 py-0.5 rounded-full whitespace-nowrap ${statusColor(order.status)}`}>
|
||||||
|
<span aria-hidden="true" className="w-1.5 h-1.5 rounded-full bg-current" />
|
||||||
|
{statusLabel(order.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[12px] font-semibold text-[#111827] font-mono whitespace-nowrap">
|
||||||
|
{fmtAmount(order.totalAmount)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { clearSession(); window.location.reload(); }}
|
||||||
|
className="mt-4 text-[11px] text-[#9CA3AF] hover:text-red-400 underline transition-colors"
|
||||||
|
>
|
||||||
|
مسح الجلسة وإعادة البدء
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardStep;
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// src/Components/Trip/TripDetailModal.tsx
|
|
||||||
// New — split out of the old single-file Trip module, mirroring
|
|
||||||
// UserDetailModal.tsx / ArchivedDriverDetailModal.tsx for consistency.
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Alert, Button, Modal, Spinner } from "../UI";
|
import { Alert, Button, Modal, Spinner } from "../UI";
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// src/Components/Trip/TripTable.tsx
|
// src/Components/Trip/TripTable.tsx
|
||||||
// New — split out of the old single-file Trip module, mirroring
|
// MIGRATED to ReusableTable + ActionButtons — same props, same visual
|
||||||
// UserTable.tsx for consistency.
|
// result as the original. StatusBadge kept custom since colors come
|
||||||
|
// dynamically from TRIP_STATUS_MAP (per-status color+dot).
|
||||||
|
|
||||||
import { Button, EmptyState, IconBtn, Spinner } from "../UI";
|
import { Button, ReusableTable, ActionButtons } from "../UI";
|
||||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||||
|
import type { TableColumn } from "@/src/types/models";
|
||||||
import type { Trip } from "@/src/types/trip";
|
import type { Trip } from "@/src/types/trip";
|
||||||
|
|
||||||
// ── status badge ─────────────────────────────────────────────────────────────
|
|
||||||
// Kept custom rather than swapped for <Badge/>: colors come dynamically from
|
|
||||||
// TRIP_STATUS_MAP (per-status color+dot), which Badge's fixed palette
|
|
||||||
// doesn't cover — mirrors DriverTable's StatusBadge.
|
|
||||||
function StatusBadge({ status }: { status: Trip["status"] }) {
|
function StatusBadge({ status }: { status: Trip["status"] }) {
|
||||||
const s = TRIP_STATUS_MAP[status];
|
const s = TRIP_STATUS_MAP[status];
|
||||||
return (
|
return (
|
||||||
@@ -22,151 +20,84 @@ function StatusBadge({ status }: { status: Trip["status"] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── card / header styles ─────────────────────────────────────────────────────
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ROW_GRID_COLUMNS = "2fr 1.3fr 1.3fr 1fr 1fr 1fr 100px";
|
|
||||||
|
|
||||||
function fmtDate(iso?: string | null): string {
|
function fmtDate(iso?: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" });
|
return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Props ────────────────────────────────────────────────────────────────────
|
|
||||||
interface TripTableProps {
|
interface TripTableProps {
|
||||||
trips: Trip[];
|
trips: Trip[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
search: string;
|
search: string;
|
||||||
page: number;
|
page: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
onEdit: (trip: Trip) => void;
|
onEdit: (trip: Trip) => void;
|
||||||
onDelete: (trip: Trip) => void;
|
onDelete: (trip: Trip) => void;
|
||||||
onView: (trip: Trip) => void;
|
onView: (trip: Trip) => void;
|
||||||
onAddFirst: () => void;
|
onAddFirst: () => void;
|
||||||
onPageChange: (p: number) => void;
|
onPageChange: (p: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── main table ───────────────────────────────────────────────────────────────
|
export function TripTable({
|
||||||
export function TripTable({ trips, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: TripTableProps) {
|
trips, loading, search, page, pages,
|
||||||
|
onEdit, onDelete, onView, onAddFirst, onPageChange,
|
||||||
|
}: TripTableProps) {
|
||||||
|
const columns: TableColumn<Trip>[] = [
|
||||||
|
{
|
||||||
|
key: "title",
|
||||||
|
header: "الرحلة",
|
||||||
|
width: "2fr",
|
||||||
|
render: (t) => (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{t.title}</p>
|
||||||
|
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{t.tripNumber}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "driver", header: "السائق", width: "1.3fr", render: (t) => t.driver?.name ?? "—" },
|
||||||
|
{
|
||||||
|
key: "car",
|
||||||
|
header: "السيارة",
|
||||||
|
width: "1.3fr",
|
||||||
|
render: (t) => (t.car ? `${t.car.manufacturer} ${t.car.model}` : "—"),
|
||||||
|
},
|
||||||
|
{ key: "branch", header: "الفرع", width: "1fr", render: (t) => t.branch?.name ?? "—" },
|
||||||
|
{ key: "status", header: "الحالة", width: "1fr", render: (t) => <StatusBadge status={t.status} /> },
|
||||||
|
{
|
||||||
|
key: "startTime",
|
||||||
|
header: "معاد البداء",
|
||||||
|
width: "1fr",
|
||||||
|
align: "center",
|
||||||
|
render: (t) => fmtDate(t.startTime),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardStyle}>
|
<ReusableTable
|
||||||
{/* column headers */}
|
columns={columns}
|
||||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, ...thStyle }}>
|
data={trips}
|
||||||
<span>الرحلة</span>
|
loading={loading}
|
||||||
<span>السائق</span>
|
search={search}
|
||||||
<span>السيارة</span>
|
page={page}
|
||||||
<span>الفرع</span>
|
pages={pages}
|
||||||
<span>الحالة</span>
|
onPageChange={onPageChange}
|
||||||
<span style={{ textAlign: "center" }}>وقت البدء</span>
|
emptyIcon="🚚"
|
||||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
emptyDescription={!search ? "No trips to display." : undefined}
|
||||||
</div>
|
emptyAction={
|
||||||
|
!search && (
|
||||||
{/* loading state */}
|
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||||
{loading ? (
|
Add first trip
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
</Button>
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
)
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
}
|
||||||
</div>
|
renderActions={(t) => (
|
||||||
) : trips.length === 0 ? (
|
<ActionButtons
|
||||||
/* empty state */
|
itemLabel={t.title}
|
||||||
<EmptyState
|
onView={() => onView(t)}
|
||||||
icon="🚚"
|
onEdit={() => onEdit(t)}
|
||||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد رحلات لعرضها."}
|
onDelete={() => onDelete(t)}
|
||||||
action={
|
|
||||||
!search && (
|
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
|
||||||
أضف أول رحلة
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
/* data rows */
|
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
|
||||||
{trips.map((t, i) => (
|
|
||||||
<li key={t.id} style={{
|
|
||||||
display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS,
|
|
||||||
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
|
||||||
fontSize: 13,
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{t.title}</p>
|
|
||||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{t.tripNumber}</p>
|
|
||||||
</div>
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>{t.driver?.name ?? "—"}</span>
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>{t.car ? `${t.car.manufacturer} ${t.car.model}` : "—"}</span>
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>{t.branch?.name ?? "—"}</span>
|
|
||||||
<StatusBadge status={t.status} />
|
|
||||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
|
||||||
{fmtDate(t.startTime)}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
|
||||||
{/* view */}
|
|
||||||
<IconBtn title={`عرض ${t.title}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(t)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
{/* edit */}
|
|
||||||
<IconBtn title={`تعديل ${t.title}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(t)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
{/* delete */}
|
|
||||||
<IconBtn title={`حذف ${t.title}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(t)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
{/* pagination */}
|
|
||||||
{pages > 1 && (
|
|
||||||
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
>
|
|
||||||
السابق
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
|
||||||
disabled={page === pages}
|
|
||||||
>
|
|
||||||
التالي
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
99
src/Components/Trip/Tripdeletemodal.tsx
Normal file
99
src/Components/Trip/Tripdeletemodal.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import type { Trip } from "@/src/types/trip";
|
||||||
|
|
||||||
|
interface TripDeleteModalProps {
|
||||||
|
trip: Trip;
|
||||||
|
deleting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TripDeleteModal({ trip, deleting, onCancel, onConfirm }: TripDeleteModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alertdialog" aria-modal="true" aria-labelledby="trip-del-title"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 60,
|
||||||
|
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%", maxWidth: 420,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
||||||
|
padding: "2rem",
|
||||||
|
display: "flex", flexDirection: "column", gap: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52, margin: "0 auto", borderRadius: "50%",
|
||||||
|
background: "#FEF2F2", border: "1px solid #FECACA",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 id="trip-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
|
حذف الرحلة
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
||||||
|
هل أنت متأكد من حذف رحلة{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>{trip.title}</strong>
|
||||||
|
{" "}(
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||||
|
{trip.tripNumber}
|
||||||
|
</span>
|
||||||
|
)؟ لا يمكن التراجع عن هذا الإجراء.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button type="button" onClick={onCancel} disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)", background: "var(--color-surface)",
|
||||||
|
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
|
||||||
|
cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)",
|
||||||
|
}}>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onConfirm} disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
||||||
|
border: "none", background: "#DC2626",
|
||||||
|
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||||
|
cursor: deleting ? "not-allowed" : "pointer",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1,
|
||||||
|
}}>
|
||||||
|
{deleting && <Spinner size="sm" className="text-white" />}
|
||||||
|
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// src/Components/Trip/TripFormModal.tsx
|
|
||||||
// CHANGE: renamed from Tripformmodal.tsx (proper PascalCase, matches
|
|
||||||
// UserFormModal.tsx / DriverFormModal.tsx). Split out of the old single-file
|
|
||||||
// Trip module into TripFormModal.tsx / TripTable.tsx / TripDetailModal.tsx.
|
|
||||||
// FIX 1: notes/endReason schema — empty string now transforms to undefined
|
|
||||||
// before .min() runs, so leaving them blank on create no longer fails
|
|
||||||
// validation despite .optional() (see trip.validator.ts).
|
|
||||||
// FIX 2: edit-mode defaultValues now fall back to the nested driver/car/branch
|
|
||||||
// ids, and setValue() re-applies the saved id once each dropdown's options
|
|
||||||
// actually load — previously the <select> had no matching <option> yet when
|
|
||||||
// defaultValues were first applied, so the old selection silently reset to "".
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Button, Input, Modal, Select, Textarea } from "../UI";
|
import { Alert, Button, Input, Modal, Select, Textarea } from "../UI";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
|
import { useEditFormSync } from "@/src/hooks/useEditFormSync";
|
||||||
import {
|
import {
|
||||||
createTripSchema,
|
createTripSchema,
|
||||||
updateTripSchema,
|
updateTripSchema,
|
||||||
@@ -138,30 +127,25 @@ export function TripFormModal({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
// ── Fetch dropdown options once on mount ────────────────────────────────────
|
||||||
const token = getStoredToken();
|
// CHANGE: split fetching from syncing — fetching stays a plain useEffect,
|
||||||
|
// syncing the saved id back into the form is now handled by useEditFormSync
|
||||||
|
// below, once per field, instead of being inlined into each .then().
|
||||||
|
useEffect(() => {
|
||||||
|
const token = getStoredToken();
|
||||||
|
|
||||||
driverService.getActiveOptions(token).then((list) => {
|
driverService.getActiveOptions(token).then(setDrivers).catch(() => {});
|
||||||
setDrivers(list);
|
carService.getActiveOptions(token).then(setCars).catch(() => {});
|
||||||
// defaultValues were applied before this list existed, so the <select>
|
branchService.getOptions(token).then(setBranches).catch(() => {});
|
||||||
// had no matching <option> yet and silently fell back to "" — reapply
|
}, []);
|
||||||
// the saved id now that the option actually exists in the DOM.
|
|
||||||
const savedDriverId = editTrip?.driverId ?? editTrip?.driver?.id;
|
|
||||||
if (savedDriverId) setValue("driverId", savedDriverId);
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
carService.getActiveOptions(token).then((list) => {
|
// CHANGE: replaced the three manual `if (savedXId) setValue(...)` calls
|
||||||
setCars(list);
|
// (previously inlined inside each .then() above) with useEditFormSync —
|
||||||
const savedCarId = editTrip?.carId ?? editTrip?.car?.id;
|
// each hook call re-applies the saved id once its matching option list
|
||||||
if (savedCarId) setValue("carId", savedCarId);
|
// has loaded, regardless of which fetch resolves first.
|
||||||
}).catch(() => {});
|
useEditFormSync(setValue, "driverId", editTrip?.driverId ?? editTrip?.driver?.id, drivers);
|
||||||
|
useEditFormSync(setValue, "carId", editTrip?.carId ?? editTrip?.car?.id, cars);
|
||||||
branchService.getOptions(token).then((list) => {
|
useEditFormSync(setValue, "branchId", editTrip?.branchId ?? editTrip?.branch?.id, branches);
|
||||||
setBranches(list);
|
|
||||||
const savedBranchId = editTrip?.branchId ?? editTrip?.branch?.id;
|
|
||||||
if (savedBranchId) setValue("branchId", savedBranchId);
|
|
||||||
}).catch(() => {});
|
|
||||||
}, [editTrip, setValue]);
|
|
||||||
|
|
||||||
const [apiError, setApiError] = useState("");
|
const [apiError, setApiError] = useState("");
|
||||||
|
|
||||||
|
|||||||
167
src/Components/Trip/archive/ArchivedTripList.tsx
Normal file
167
src/Components/Trip/archive/ArchivedTripList.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Spinner, Alert, EmptyState } from "@/src/Components/UI";
|
||||||
|
import { useArchivedTrips } from "@/src/hooks/archive/useArchivedTrips";
|
||||||
|
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||||
|
import type { Trip } from "@/src/types/trip";
|
||||||
|
|
||||||
|
interface ArchivedTripListProps {
|
||||||
|
/** Called when the user clicks a row to view trip details */
|
||||||
|
onView?: (trip: Trip) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status badge — reuses the existing trip status color map ────────────────
|
||||||
|
function TripStatusBadge({ status }: { status: Trip["status"] }) {
|
||||||
|
const s = TRIP_STATUS_MAP[status];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-bold"
|
||||||
|
style={{ color: s.color, background: s.bg, border: `1px solid ${s.border}` }}
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Date formatting helper ───────────────────────────────────────────────────
|
||||||
|
function fmtDate(iso?: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArchivedTripList
|
||||||
|
* Displays a paginated, searchable list of archived trips.
|
||||||
|
* Fetches data via useArchivedTrips (GET /trip/archived), handles loading,
|
||||||
|
* empty, and error states, and renders each trip with its key details.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <ArchivedTripList onView={(trip) => setViewTripId(trip.id)} />
|
||||||
|
*/
|
||||||
|
export function ArchivedTripList({ onView }: ArchivedTripListProps) {
|
||||||
|
const {
|
||||||
|
trips, loading, total, pages, page, search, error,
|
||||||
|
setPage, handleSearch, clearError,
|
||||||
|
} = useArchivedTrips();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div dir="rtl" className="flex flex-col gap-4">
|
||||||
|
{/* ── Header: count + search ── */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h2 className="text-base font-bold text-[var(--color-text-primary)]">
|
||||||
|
الرحلات المؤرشفة
|
||||||
|
<span className="mr-2 text-[13px] font-medium text-[var(--color-text-muted)]">
|
||||||
|
({total})
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:w-72">
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--color-text-hint)]"
|
||||||
|
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="بحث برقم الرحلة أو العنوان..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
className="h-10 w-full rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] pr-9 pl-3 text-[13px] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-brand-600)] focus:ring-2 focus:ring-[var(--color-brand-600)]/15"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── API error feedback ── */}
|
||||||
|
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||||
|
|
||||||
|
{/* ── Card ── */}
|
||||||
|
<div className="overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]">
|
||||||
|
{/* column headers */}
|
||||||
|
<div className="grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-muted)] px-6 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
|
<span>الرحلة</span>
|
||||||
|
<span>الحالة</span>
|
||||||
|
<span className="text-center">البدء</span>
|
||||||
|
<span className="text-center">الانتهاء</span>
|
||||||
|
<span className="text-center">النقد المحصّل</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Loading state ── */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-3 py-16 text-[var(--color-text-muted)]">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<span className="text-[13px]">جارٍ التحميل…</span>
|
||||||
|
</div>
|
||||||
|
) : trips.length === 0 ? (
|
||||||
|
/* ── Empty state ── */
|
||||||
|
<EmptyState
|
||||||
|
icon="🗄️"
|
||||||
|
title="لا توجد رحلات مؤرشفة"
|
||||||
|
description={search ? `لا توجد نتائج لـ "${search}"` : "لم يتم أرشفة أي رحلات بعد."}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
/* ── Data rows ── */
|
||||||
|
<ul className="m-0 list-none p-0">
|
||||||
|
{trips.map((trip, i) => (
|
||||||
|
<li
|
||||||
|
key={trip.id}
|
||||||
|
onClick={() => onView?.(trip)}
|
||||||
|
className={`grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] items-center gap-2 border-b border-[var(--color-border)] px-6 py-3.5 text-[13px] transition-colors hover:bg-[var(--color-surface-muted)] ${
|
||||||
|
onView ? "cursor-pointer" : ""
|
||||||
|
} ${i % 2 !== 0 ? "bg-[var(--color-surface-muted)]/40" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="m-0 truncate font-semibold text-[var(--color-text-primary)]">{trip.title}</p>
|
||||||
|
<p className="mt-0.5 font-mono text-[11px] text-[var(--color-brand-600)]">{trip.tripNumber}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<TripStatusBadge status={trip.status} />
|
||||||
|
</div>
|
||||||
|
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
{fmtDate(trip.startTime)}
|
||||||
|
</span>
|
||||||
|
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
{fmtDate(trip.endTime)}
|
||||||
|
</span>
|
||||||
|
<span className="text-center font-mono text-[12px] font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{trip.totalCashCollected != null
|
||||||
|
? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س`
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Pagination ── */}
|
||||||
|
{pages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-6 py-3.5">
|
||||||
|
<span className="text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
صفحة <strong className="text-[var(--color-text-primary)]">{page}</strong> من{" "}
|
||||||
|
<strong className="text-[var(--color-text-primary)]">{pages}</strong>
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page === 1}
|
||||||
|
onClick={() => setPage(Math.max(1, page - 1))}
|
||||||
|
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3.5 py-1.5 text-[12px] text-[var(--color-text-secondary)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
السابق
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page === pages}
|
||||||
|
onClick={() => setPage(Math.min(pages, page + 1))}
|
||||||
|
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3.5 py-1.5 text-[12px] text-[var(--color-text-secondary)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
التالي
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// src/Components/Trip/archive/ArchivedTripModal.tsx
|
|
||||||
// Split out of the old ArchivedTripList.tsx — this is the top-level modal
|
|
||||||
// wrapper (search bar + ArchivedTripTable + opens ArchivedTripDetailModal),
|
|
||||||
// mirroring ArchivedDrivers.tsx / ArchivedUsersModal.tsx.
|
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Input, Modal } from "@/src/Components/UI";
|
import { Alert, Input, Modal } from "@/src/Components/UI";
|
||||||
@@ -16,18 +13,7 @@ interface ArchivedTripModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchIcon = (
|
const searchIcon = (
|
||||||
<svg
|
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,135 +1,79 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// src/Components/Trip/archive/ArchivedTripTable.tsx
|
import { ReusableTable } from "@/src/Components/UI";
|
||||||
// Split out of the old ArchivedTripList.tsx — this is just the table portion
|
|
||||||
// (headers, rows, pagination), mirroring ArchivedDriverTable.tsx. Styling is
|
|
||||||
// preserved exactly (Tailwind classes) as it was in the original file.
|
|
||||||
|
|
||||||
import { Spinner, EmptyState, Button } from "@/src/Components/UI";
|
|
||||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||||
|
import type { TableColumn } from "@/src/types/models";
|
||||||
import type { Trip } from "@/src/types/trip";
|
import type { Trip } from "@/src/types/trip";
|
||||||
|
|
||||||
// ── Status badge — reuses the existing trip status color map ────────────────
|
|
||||||
function TripStatusBadge({ status }: { status: Trip["status"] }) {
|
|
||||||
const s = TRIP_STATUS_MAP[status];
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-bold"
|
|
||||||
style={{ color: s.color, background: s.bg, border: `1px solid ${s.border}` }}
|
|
||||||
>
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />
|
|
||||||
{s.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Date formatting helper ───────────────────────────────────────────────────
|
|
||||||
function fmtDate(iso?: string | null): string {
|
function fmtDate(iso?: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" });
|
return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ArchivedTripTableProps {
|
interface ArchivedTripTableProps {
|
||||||
trips: Trip[];
|
trips: Trip[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
search: string;
|
search: string;
|
||||||
page: number;
|
page: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
onView: (trip: Trip) => void;
|
onView: (trip: Trip) => void;
|
||||||
onPageChange: (p: number) => void;
|
onPageChange: (p: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ArchivedTripTable
|
|
||||||
* Renders the paginated, searchable table of archived trips, including
|
|
||||||
* loading, empty, and data-row states. Mirrors ArchivedDriverTable.tsx.
|
|
||||||
*/
|
|
||||||
export function ArchivedTripTable({ trips, loading, search, page, pages, onView, onPageChange }: ArchivedTripTableProps) {
|
export function ArchivedTripTable({ trips, loading, search, page, pages, onView, onPageChange }: ArchivedTripTableProps) {
|
||||||
return (
|
const columns: TableColumn<Trip>[] = [
|
||||||
<div className="overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]">
|
{
|
||||||
{/* column headers */}
|
key: "title",
|
||||||
<div className="grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-muted)] px-6 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
header: "الرحلة",
|
||||||
<span>الرحلة</span>
|
width: "2fr",
|
||||||
<span>الحالة</span>
|
render: (t) => (
|
||||||
<span className="text-center">البدء</span>
|
<div>
|
||||||
<span className="text-center">الانتهاء</span>
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{t.title}</p>
|
||||||
<span className="text-center">النقد المحصّل</span>
|
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-brand-600)" }}>{t.tripNumber}</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Loading state ── */}
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center gap-3 py-16 text-[var(--color-text-muted)]">
|
|
||||||
<Spinner size="sm" />
|
|
||||||
<span className="text-[13px]">جارٍ التحميل…</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : trips.length === 0 ? (
|
),
|
||||||
/* ── Empty state ── */
|
},
|
||||||
<EmptyState
|
{
|
||||||
icon="🗄️"
|
key: "status",
|
||||||
title="لا توجد رحلات مؤرشفة"
|
header: "الحالة",
|
||||||
description={search ? `لا توجد نتائج لـ "${search}"` : "لم يتم أرشفة أي رحلات بعد."}
|
width: "1.5fr",
|
||||||
/>
|
render: (t) => {
|
||||||
) : (
|
const s = TRIP_STATUS_MAP[t.status];
|
||||||
/* ── Data rows ── */
|
return (
|
||||||
<ul className="m-0 list-none p-0">
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: s.color }}>
|
||||||
{trips.map((trip, i) => (
|
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
|
||||||
<li
|
{s.label}
|
||||||
key={trip.id}
|
|
||||||
onClick={() => onView(trip)}
|
|
||||||
className={`grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] items-center gap-2 border-b border-[var(--color-border)] px-6 py-3.5 text-[13px] transition-colors hover:bg-[var(--color-surface-muted)] cursor-pointer ${
|
|
||||||
i % 2 !== 0 ? "bg-[var(--color-surface-muted)]/40" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="m-0 truncate font-semibold text-[var(--color-text-primary)]">{trip.title}</p>
|
|
||||||
<p className="mt-0.5 font-mono text-[11px] text-[var(--color-brand-600)]">{trip.tripNumber}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<TripStatusBadge status={trip.status} />
|
|
||||||
</div>
|
|
||||||
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
|
|
||||||
{fmtDate(trip.startTime)}
|
|
||||||
</span>
|
|
||||||
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
|
|
||||||
{fmtDate(trip.endTime)}
|
|
||||||
</span>
|
|
||||||
<span className="text-center font-mono text-[12px] font-semibold text-[var(--color-text-primary)]">
|
|
||||||
{trip.totalCashCollected != null
|
|
||||||
? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س`
|
|
||||||
: "—"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Pagination ── */}
|
|
||||||
{pages > 1 && (
|
|
||||||
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-6 py-3.5">
|
|
||||||
<span className="text-[12px] text-[var(--color-text-muted)]">
|
|
||||||
صفحة <strong className="text-[var(--color-text-primary)]">{page}</strong> من{" "}
|
|
||||||
<strong className="text-[var(--color-text-primary)]">{pages}</strong>
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
);
|
||||||
<Button
|
},
|
||||||
variant="secondary"
|
},
|
||||||
size="sm"
|
{ key: "start", header: "تاريخ البداية", width: "1fr", align: "center", render: (t) => fmtDate(t.startTime) },
|
||||||
disabled={page === 1}
|
{ key: "end", header: "تاريخ الانتهاء", width: "1fr", align: "center", render: (t) => fmtDate(t.endTime) },
|
||||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
{
|
||||||
>
|
key: "cash",
|
||||||
السابق
|
header: "المبلغ المحصل",
|
||||||
</Button>
|
width: "1fr",
|
||||||
<Button
|
align: "center",
|
||||||
variant="secondary"
|
render: (t) => (
|
||||||
size="sm"
|
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)" }}>
|
||||||
disabled={page === pages}
|
{t.totalCashCollected != null ? `${Number(t.totalCashCollected).toLocaleString("en-US")} SAR` : "—"}
|
||||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
</span>
|
||||||
>
|
),
|
||||||
التالي
|
},
|
||||||
</Button>
|
];
|
||||||
</div>
|
|
||||||
</div>
|
return (
|
||||||
)}
|
<ReusableTable
|
||||||
</div>
|
columns={columns}
|
||||||
|
data={trips}
|
||||||
|
loading={loading}
|
||||||
|
search={search}
|
||||||
|
page={page}
|
||||||
|
pages={pages}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onRowClick={onView}
|
||||||
|
emptyIcon="🗄️"
|
||||||
|
emptyDescription={search ? `No results for "${search}"` : "No archived trips."}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
45
src/Components/UI/ActionButtons.tsx
Normal file
45
src/Components/UI/ActionButtons.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { IconBtn } from "./IconBtn";
|
||||||
|
import type { ActionButtonsProps } from "@/src/types/models";
|
||||||
|
|
||||||
|
|
||||||
|
const ViewIcon = <i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />;
|
||||||
|
|
||||||
|
const EditIcon = <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />;
|
||||||
|
|
||||||
|
const DeleteIcon = <i className="ti ti-trash" style={{ fontSize: 14 }} aria-hidden="true" />;
|
||||||
|
|
||||||
|
export function ActionButtons({
|
||||||
|
onView,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
itemLabel = "",
|
||||||
|
viewLabel = "View",
|
||||||
|
editLabel = "Edit",
|
||||||
|
deleteLabel = "Delete",
|
||||||
|
}: ActionButtonsProps) {
|
||||||
|
const suffix = itemLabel ? ` ${itemLabel}` : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{onView && (
|
||||||
|
<IconBtn title={`${viewLabel}${suffix}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={onView}>
|
||||||
|
{ViewIcon}
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
|
{onEdit && (
|
||||||
|
<IconBtn title={`${editLabel}${suffix}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={onEdit}>
|
||||||
|
{EditIcon}
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<IconBtn title={`${deleteLabel}${suffix}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={onDelete}>
|
||||||
|
{DeleteIcon}
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,11 +50,7 @@ export function ArchiveButton({
|
|||||||
onMouseEnter={e => (e.currentTarget.style.transform = "translateY(-2px)")}
|
onMouseEnter={e => (e.currentTarget.style.transform = "translateY(-2px)")}
|
||||||
onMouseLeave={e => (e.currentTarget.style.transform = "translateY(0)")}
|
onMouseLeave={e => (e.currentTarget.style.transform = "translateY(0)")}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<i className="ti ti-archive" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||||
<rect x="2" y="4" width="20" height="5" rx="1" />
|
|
||||||
<path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9" />
|
|
||||||
<path d="M10 13h4" />
|
|
||||||
</svg>
|
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { Button } from "./Button";
|
||||||
|
import { Input } from "./Input";
|
||||||
|
import { Select } from "./Select";
|
||||||
|
|
||||||
export default function Header({
|
export default function Header({
|
||||||
state,
|
state,
|
||||||
search,
|
search,
|
||||||
@@ -75,50 +79,30 @@ export default function Header({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
alignItems: "center",
|
alignItems: "flex-end",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
|
label="بحث"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="بحث "
|
placeholder="ابحث..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value);
|
setSearch(e.target.value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
style={{
|
className="w-[220px]"
|
||||||
width: 220,
|
|
||||||
height: 40,
|
|
||||||
padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{isAudit ? (
|
{isAudit ? (
|
||||||
<select
|
<Select
|
||||||
value={module}
|
value={module}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setModule(e.target.value);
|
setModule(e.target.value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
style={{
|
className="w-[180px]"
|
||||||
height: 40,
|
|
||||||
padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<option value="">كل الوحدات</option>
|
<option value="">كل الوحدات</option>
|
||||||
{[
|
{[
|
||||||
@@ -135,42 +119,12 @@ export default function Header({
|
|||||||
{m}
|
{m}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Button onClick={onAdd}>
|
||||||
type="button"
|
<i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
onClick={onAdd}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: "0 1.125rem",
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "none",
|
|
||||||
background: "var(--color-brand-600)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#FFF",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 7,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
>
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
إضافة {name}
|
إضافة {name}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
57
src/Components/UI/ModelCard.tsx
Normal file
57
src/Components/UI/ModelCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// src/Components/UI/ModelCard.tsx
|
||||||
|
//
|
||||||
|
// Optional thin wrapper matching the "card with a title row above a table"
|
||||||
|
// shape used e.g. around ArchivedTripList's header, OrderComponent's
|
||||||
|
// header, and the various archive modals (count + search + table).
|
||||||
|
// It intentionally does NOT duplicate Header.tsx (the full page-level
|
||||||
|
// title/search/add-button bar already used on every dashboard page) —
|
||||||
|
// it's a lighter-weight wrapper for cases where you just need a labeled
|
||||||
|
// card around a <ReusableTable/>, e.g. inside a modal or detail panel.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// <ModelCard title="Orders" count={total} countLabel="orders" action={<SearchBox />}>
|
||||||
|
// <ReusableTable columns={columns} data={orders} ... />
|
||||||
|
// </ModelCard>
|
||||||
|
|
||||||
|
import type { ModelCardProps } from "@/src/types/models";
|
||||||
|
|
||||||
|
export function ModelCard({ title, count, countLabel, action, children }: ModelCardProps) {
|
||||||
|
const hasHeader = title || count !== undefined || action;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
|
{hasHeader && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "0.75rem",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(title || count !== undefined) && (
|
||||||
|
<h2 style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
|
{title}
|
||||||
|
{count !== undefined && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginInlineStart: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
({count}{countLabel ? ` ${countLabel}` : ""})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{action && <div style={{ flexShrink: 0 }}>{action}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
src/Components/UI/ReusableTable.tsx
Normal file
273
src/Components/UI/ReusableTable.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// src/Components/UI/ReusableTable.tsx
|
||||||
|
//
|
||||||
|
// ============================================================================
|
||||||
|
// HOW TO USE
|
||||||
|
// ============================================================================
|
||||||
|
// ReusableTable replaces the hand-written <table>/<ul><li> markup that is
|
||||||
|
// currently duplicated across UserTable.tsx, BranchTable.tsx, RoleTable.tsx,
|
||||||
|
// TripTable.tsx, OrderTable.tsx, ArchivedDriverTable.tsx, etc. Those files
|
||||||
|
// all share the exact same shape: a grid header row, a loading spinner, an
|
||||||
|
// empty state, data rows built from `gridTemplateColumns`, and a two-button
|
||||||
|
// pager — only the column definitions and cell content differ per model.
|
||||||
|
//
|
||||||
|
// import { ReusableTable, ActionButtons } from "@/src/Components/UI";
|
||||||
|
// import type { TableColumn } from "@/src/types/models";
|
||||||
|
// import type { User } from "@/src/types/user";
|
||||||
|
//
|
||||||
|
// const columns: TableColumn<User>[] = [
|
||||||
|
// {
|
||||||
|
// key: "name",
|
||||||
|
// header: "Name",
|
||||||
|
// width: "2fr",
|
||||||
|
// render: (u) => (
|
||||||
|
// <div>
|
||||||
|
// <p style={{ fontWeight: 600 }}>{u.name}</p>
|
||||||
|
// <p style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
|
||||||
|
// </div>
|
||||||
|
// ),
|
||||||
|
// },
|
||||||
|
// { key: "role", header: "Role", width: "1fr", render: (u) => u.role?.name ?? "—" },
|
||||||
|
// ];
|
||||||
|
//
|
||||||
|
// <ReusableTable
|
||||||
|
// columns={columns}
|
||||||
|
// data={users}
|
||||||
|
// loading={loading}
|
||||||
|
// search={search}
|
||||||
|
// page={page}
|
||||||
|
// pages={pages}
|
||||||
|
// onPageChange={setPage}
|
||||||
|
// onRowClick={(u) => setViewUserId(u.id)}
|
||||||
|
// renderActions={(u) => (
|
||||||
|
// <ActionButtons
|
||||||
|
// itemLabel={u.name}
|
||||||
|
// onView={() => setViewUserId(u.id)}
|
||||||
|
// onEdit={() => setFormTarget(u)}
|
||||||
|
// onDelete={() => setDeleteTarget(u)}
|
||||||
|
// />
|
||||||
|
// )}
|
||||||
|
// />
|
||||||
|
//
|
||||||
|
// This produces byte-for-byte the same visual result as the existing
|
||||||
|
// UserTable — same cardStyle border/radius/shadow, same thStyle header
|
||||||
|
// treatment, same zebra-striped rows, same "السابق/التالي"-style pager
|
||||||
|
// (labels are passed as plain strings, so RTL/Arabic labels work exactly
|
||||||
|
// as before — nothing about the language is hardcoded here).
|
||||||
|
//
|
||||||
|
// NOTE ON STYLING: most tables in this codebase (User/Branch/Role/Trip/
|
||||||
|
// Order/Driver-archive) use inline `React.CSSProperties` objects with CSS
|
||||||
|
// custom properties (`var(--color-border)`, etc). A smaller set of newer
|
||||||
|
// files (ArchivedTripTable, ClientTable's expandable rows) use Tailwind
|
||||||
|
// utility classes against the same tokens instead. This component follows
|
||||||
|
// the majority inline-style pattern so it's a drop-in replacement for the
|
||||||
|
// most common case; the two Tailwind-based tables would need a follow-up
|
||||||
|
// pass to fully consolidate (flagged here rather than silently converted,
|
||||||
|
// since swapping their styling approach is a separate, riskier change).
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { EmptyState } from "./EmptyState";
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
import type { CrudTableProps, BaseEntity } from "@/src/types/models";
|
||||||
|
|
||||||
|
// ── Shared styles — identical values to UserTable.tsx / BranchTable.tsx /
|
||||||
|
// RoleTable.tsx / TripTable.tsx / OrderTable.tsx ──────────────────────────
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const thStyle: React.CSSProperties = {
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.2em",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReusableTable<T extends BaseEntity>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
loading = false,
|
||||||
|
keyExtractor = (item: T) => item.id,
|
||||||
|
onRowClick,
|
||||||
|
search,
|
||||||
|
emptyIcon = "📋",
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
emptyAction,
|
||||||
|
page,
|
||||||
|
pages,
|
||||||
|
onPageChange,
|
||||||
|
renderActions,
|
||||||
|
actionsHeader = "إجراءات",
|
||||||
|
actionsWidth = "100px",
|
||||||
|
renderExpanded,
|
||||||
|
}: CrudTableProps<T>) {
|
||||||
|
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const gridTemplateColumns = [
|
||||||
|
...columns.map((c) => c.width ?? "1fr"),
|
||||||
|
...(renderActions ? [actionsWidth] : []),
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
const resolvedEmptyTitle =
|
||||||
|
emptyTitle ?? (search ? `No results for "${search}"` : "No records to display.");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={cardStyle}>
|
||||||
|
{/* ── Column headers ── */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns, ...thStyle }}>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<span key={c.key} style={{ textAlign: c.align }}>
|
||||||
|
{c.header}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{renderActions && (
|
||||||
|
<span style={{ textAlign: "center" }}>{actionsHeader}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Loading state ── */}
|
||||||
|
{loading ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "4rem 0",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spinner size="sm" className="text-blue-600" />
|
||||||
|
<span style={{ fontSize: 13 }}>Loading…</span>
|
||||||
|
</div>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
/* ── Empty state ── */
|
||||||
|
<EmptyState
|
||||||
|
icon={emptyIcon}
|
||||||
|
title={resolvedEmptyTitle}
|
||||||
|
description={emptyDescription}
|
||||||
|
action={emptyAction}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
/* ── Data rows ── */
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
|
{data.map((item, i) => {
|
||||||
|
const key = keyExtractor(item);
|
||||||
|
const isExpanded = !!renderExpanded && expandedKey === key;
|
||||||
|
const handleRowClick = renderExpanded
|
||||||
|
? () => setExpandedKey((prev) => (prev === key ? null : key))
|
||||||
|
: onRowClick
|
||||||
|
? () => onRowClick(item)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={key}>
|
||||||
|
<div
|
||||||
|
onClick={handleRowClick}
|
||||||
|
role={renderExpanded ? "button" : undefined}
|
||||||
|
tabIndex={renderExpanded ? 0 : undefined}
|
||||||
|
aria-expanded={renderExpanded ? isExpanded : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
renderExpanded
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") handleRowClick?.();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.875rem 1.5rem",
|
||||||
|
borderBottom: isExpanded ? "none" : "1px solid var(--color-border)",
|
||||||
|
borderInlineStart: renderExpanded
|
||||||
|
? isExpanded
|
||||||
|
? "3px solid var(--color-brand-600)"
|
||||||
|
: "3px solid transparent"
|
||||||
|
: undefined,
|
||||||
|
background: isExpanded
|
||||||
|
? "#EFF6FF"
|
||||||
|
: i % 2 !== 0
|
||||||
|
? "var(--color-surface-muted)"
|
||||||
|
: "transparent",
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: handleRowClick ? "pointer" : "default",
|
||||||
|
transition: "background 0.15s, border-color 0.15s",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<div key={c.key} style={{ textAlign: c.align }}>
|
||||||
|
{c.render(item, i, isExpanded)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{renderActions && (
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", justifyContent: "center", gap: 4 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{renderActions(item, i)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && renderExpanded && renderExpanded(item, i)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Pagination ── */}
|
||||||
|
{pages !== undefined && pages > 1 && page !== undefined && onPageChange && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
padding: "0.875rem 1.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||||
|
Page <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> of{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
السابق
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||||
|
disabled={page === pages}
|
||||||
|
>
|
||||||
|
التالي
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,10 +7,11 @@ export interface SpinnerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Font size (px) for each spinner size — replaces the old h-*/w-* SVG classes.
|
||||||
const sizeMap: Record<NonNullable<SpinnerProps["size"]>, string> = {
|
const sizeMap: Record<NonNullable<SpinnerProps["size"]>, string> = {
|
||||||
sm: "h-4 w-4",
|
sm: "16px",
|
||||||
md: "h-6 w-6",
|
md: "24px",
|
||||||
lg: "h-10 w-10 border-4",
|
lg: "40px",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,32 +27,15 @@ const sizeMap: Record<NonNullable<SpinnerProps["size"]>, string> = {
|
|||||||
*/
|
*/
|
||||||
export function Spinner({ size = "md", className }: SpinnerProps) {
|
export function Spinner({ size = "md", className }: SpinnerProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<i
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="Loading"
|
aria-label="Loading"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"motion-safe:animate-spin text-[var(--color-brand-600)] shrink-0",
|
"ti ti-loader-2 motion-safe:animate-spin text-[var(--color-brand-600)] shrink-0",
|
||||||
sizeMap[size],
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
style={{ fontSize: sizeMap[size], lineHeight: 1 }}
|
||||||
<circle
|
/>
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8v8H4z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,4 +25,7 @@ export { Badge } from "./Badge";
|
|||||||
|
|
||||||
export { ArchiveButton } from "./ArchiveButton"
|
export { ArchiveButton } from "./ArchiveButton"
|
||||||
export { FileInput } from "./FailInput"
|
export { FileInput } from "./FailInput"
|
||||||
export {IconBtn} from "./IconBtn"
|
export {IconBtn} from "./IconBtn"
|
||||||
|
export { ReusableTable } from "./ReusableTable";
|
||||||
|
export { ActionButtons } from "./ActionButtons";
|
||||||
|
export { ModelCard } from "./ModelCard";
|
||||||
58
src/Components/User/DeleteConfirmModal.tsx
Normal file
58
src/Components/User/DeleteConfirmModal.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import type { User } from "@/src/types/user";
|
||||||
|
|
||||||
|
interface DeleteConfirmModalProps {
|
||||||
|
user: User;
|
||||||
|
deleting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirmModal({ user, deleting, onCancel, onConfirm }: DeleteConfirmModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alertdialog" aria-modal="true" aria-labelledby="del-title"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
|
style={{ position: "fixed", inset: 0, zIndex: 60, background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{ width: "100%", maxWidth: 400, background: "var(--color-surface)", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", boxShadow: "0 20px 48px rgba(0,0,0,.18)", padding: "2rem", display: "flex", flexDirection: "column", gap: "1rem", textAlign: "center" }}
|
||||||
|
>
|
||||||
|
{/* icon delete */}
|
||||||
|
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 id="del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>حذف المستخدم</h2>
|
||||||
|
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
||||||
|
هل أنت متأكد من حذف <strong style={{ color: "var(--color-text-primary)" }}>{user.name}</strong>؟ لا يمكن التراجع عن هذا الإجراء.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button type="button" onClick={onCancel} disabled={deleting}
|
||||||
|
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onConfirm} disabled={deleting}
|
||||||
|
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
|
||||||
|
{deleting && <Spinner size="sm" className="text-white" />}
|
||||||
|
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import type { Resolver } from "react-hook-form";
|
import type { Resolver } from "react-hook-form";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Button, Input, Select } from "../UI";
|
import { Alert, Button, Input, Select } from "../UI";
|
||||||
import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator";
|
import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator";
|
||||||
|
import { useEditFormSync } from "@/src/hooks/useEditFormSync"; // CHANGE: added hook — fixes roleId/branchId not pre-selecting on edit
|
||||||
import type { Branch } from "@/src/types/branch";
|
import type { Branch } from "@/src/types/branch";
|
||||||
import type { Role } from "@/src/types/role";
|
import type { Role } from "@/src/types/role";
|
||||||
import type { User, UserFormData } from "@/src/types/user";
|
import type { User, UserFormData } from "@/src/types/user";
|
||||||
@@ -30,11 +30,6 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
const resolver: Resolver<UserFormData> = async (values, context, options) => {
|
const resolver: Resolver<UserFormData> = async (values, context, options) => {
|
||||||
const schema = isNew ? createUserSchema : updateUserSchema;
|
const schema = isNew ? createUserSchema : updateUserSchema;
|
||||||
const data = !isNew && !values.password ? { ...values, password: undefined } : values;
|
const data = !isNew && !values.password ? { ...values, password: undefined } : values;
|
||||||
// Cast both the schema argument and yupResolver's own return value — same
|
|
||||||
// fix already applied in TripFormModal/DriverFormModal/CarFormModal:
|
|
||||||
// passing an explicit <UserFormData> generic here forces a structural
|
|
||||||
// comparison between yup's inferred optional-field shape and
|
|
||||||
// UserFormData that fails the same way it did for those forms.
|
|
||||||
return (yupResolver(schema as any) as any)(data as UserFormData, context, options);
|
return (yupResolver(schema as any) as any)(data as UserFormData, context, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,25 +51,26 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// roles/branches are passed in as props rather than fetched inside this
|
// CHANGE: replaced the two manual useEffect blocks with useEditFormSync —
|
||||||
// modal (unlike Trip/Driver/Car), but the same timing issue applies if the
|
// same behavior (re-apply saved id once its option list contains it),
|
||||||
// parent still has them loading when the modal first mounts: defaultValues
|
// reused across Driver/Car/Trip forms that have the same bug pattern.
|
||||||
// are applied before the matching <option> exists, so the <select>
|
useEditFormSync(setValue, "roleId", editUser?.role?.id, roles);
|
||||||
// silently falls back to "". Reapply the saved id once each list actually
|
useEditFormSync(setValue, "branchId", editUser?.branch?.id, branches);
|
||||||
// contains it.
|
|
||||||
|
// CHANGE: surface an inline error if the saved role/branch no longer
|
||||||
|
// exists in the fetched list, instead of silently falling back to the
|
||||||
|
// placeholder with no explanation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedRoleId = editUser?.role?.id;
|
if (editUser?.role?.id && roles.length && !roles.some(r => r.id === editUser.role!.id)) {
|
||||||
if (savedRoleId && roles.some(r => r.id === savedRoleId)) {
|
setError("roleId", { message: "الدور المحفوظ لم يعد متاحًا، الرجاء اختيار دور آخر" });
|
||||||
setValue("roleId", savedRoleId);
|
|
||||||
}
|
}
|
||||||
}, [roles, editUser, setValue]);
|
}, [roles, editUser, setError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedBranchId = editUser?.branch?.id;
|
if (editUser?.branch?.id && branches.length && !branches.some(b => b.id === editUser.branch!.id)) {
|
||||||
if (savedBranchId && branches.some(b => b.id === savedBranchId)) {
|
setError("branchId", { message: "الفرع المحفوظ لم يعد متاحًا، الرجاء اختيار فرع آخر" });
|
||||||
setValue("branchId", savedBranchId);
|
|
||||||
}
|
}
|
||||||
}, [branches, editUser, setValue]);
|
}, [branches, editUser, setError]);
|
||||||
|
|
||||||
const submitHandler = async (data: UserFormData) => {
|
const submitHandler = async (data: UserFormData) => {
|
||||||
const payload: Partial<UserFormData> = { ...data };
|
const payload: Partial<UserFormData> = { ...data };
|
||||||
@@ -136,7 +132,17 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* body */}
|
{/* body */}
|
||||||
<form onSubmit={handleSubmit(submitHandler)} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
{/* CHANGE: added suppressHydrationWarning — form elements are the
|
||||||
|
most common target for browser-extension-injected attributes
|
||||||
|
(e.g. bis_skin_checked from Bitdefender), which caused the
|
||||||
|
hydration mismatch error on Save. See ExtensionAttributeCleanup
|
||||||
|
for the global fix; this is a scoped safety net. */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(submitHandler)}
|
||||||
|
noValidate
|
||||||
|
suppressHydrationWarning
|
||||||
|
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||||
|
>
|
||||||
{errors.name?.type === "manual" && (
|
{errors.name?.type === "manual" && (
|
||||||
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,158 +1,115 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../UI";
|
import { Badge, ReusableTable, ActionButtons, Button } from "@/src/Components/UI";
|
||||||
|
import type { TableColumn } from "@/src/types/models";
|
||||||
import type { User } from "@/src/types/user";
|
import type { User } from "@/src/types/user";
|
||||||
|
|
||||||
// ── status badge ─────────────────────────────────────────────────────────────
|
|
||||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
|
||||||
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
function StatusBadge({ active }: { active: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
|
||||||
|
background: active ? "#DCFCE7" : "#FEF2F2",
|
||||||
|
padding: "0.2rem 0.625rem",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: active ? "#166534" : "#991B1B",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
|
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
|
||||||
{active ? "نشط" : "معطل"}
|
{active ? "Active" : "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── card / header styles ─────────────────────────────────────────────────────
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Props ────────────────────────────────────────────────────────────────────
|
|
||||||
interface UserTableProps {
|
interface UserTableProps {
|
||||||
users: User[];
|
users: User[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
search: string;
|
search: string;
|
||||||
page: number;
|
page: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
onEdit: (user: User) => void;
|
onEdit: (user: User) => void;
|
||||||
onDelete: (user: User) => void;
|
onDelete: (user: User) => void;
|
||||||
onView: (user: User) => void;
|
onView: (user: User) => void;
|
||||||
onAddFirst: () => void;
|
onAddFirst: () => void;
|
||||||
onPageChange: (p: number) => void;
|
onPageChange: (p: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── main table ───────────────────────────────────────────────────────────────
|
export function UserTable({
|
||||||
export function UserTable({ users, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: UserTableProps) {
|
users, loading, search, page, pages,
|
||||||
|
onEdit, onDelete, onView, onAddFirst, onPageChange,
|
||||||
|
}: UserTableProps) {
|
||||||
|
const columns: TableColumn<User>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "الاسم",
|
||||||
|
width: "2fr",
|
||||||
|
render: (u) => (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p>
|
||||||
|
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
{u.phone}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "userName",
|
||||||
|
header: "اسم المستخدم",
|
||||||
|
width: "1.5fr",
|
||||||
|
render: (u) => (
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
|
||||||
|
{u.userName ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "branch", header: "الفرع", width: "1.5fr", render: (u) => u.branch?.name ?? "—" },
|
||||||
|
{
|
||||||
|
key: "role",
|
||||||
|
header: "الدور",
|
||||||
|
width: "1fr",
|
||||||
|
render: (u) => <Badge label={u.role?.name ?? "—"} color={u.role?.name === "System Admin" ? "indigo" : "slate"} />,
|
||||||
|
},
|
||||||
|
{ key: "status", header: "الحالة", width: "1fr", render: (u) => <StatusBadge active={u.isActive} /> },
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
header: "تم الانشاء",
|
||||||
|
width: "1fr",
|
||||||
|
align: "center",
|
||||||
|
render: (u) => new Date(u.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardStyle}>
|
<ReusableTable
|
||||||
{/* column headers */}
|
columns={columns}
|
||||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
|
data={users}
|
||||||
<span>الاسم</span>
|
loading={loading}
|
||||||
<span>اسم المستخدم</span>
|
search={search}
|
||||||
<span>الفرع</span>
|
page={page}
|
||||||
<span>الدور</span>
|
pages={pages}
|
||||||
<span>الحالة</span>
|
onPageChange={onPageChange}
|
||||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
emptyIcon="👥"
|
||||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
emptyDescription={!search ? "No users to display." : undefined}
|
||||||
</div>
|
emptyAction={
|
||||||
|
!search && (
|
||||||
{/* loading state */}
|
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||||
{loading ? (
|
Add first user
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
</Button>
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
)
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
}
|
||||||
</div>
|
renderActions={(u) => (
|
||||||
) : users.length === 0 ? (
|
<ActionButtons
|
||||||
/* empty state */
|
itemLabel={u.name}
|
||||||
<EmptyState
|
onView={() => onView(u)}
|
||||||
icon="👥"
|
onEdit={() => onEdit(u)}
|
||||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."}
|
onDelete={() => onDelete(u)}
|
||||||
action={
|
|
||||||
!search && (
|
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
|
||||||
أضف أول مستخدم
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
/* data rows */
|
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
|
||||||
{users.map((u, i) => (
|
|
||||||
<li key={u.id} style={{
|
|
||||||
display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 100px",
|
|
||||||
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
|
||||||
fontSize: 13,
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p>
|
|
||||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>{u.branch?.name ?? "—"}</span>
|
|
||||||
<Badge label={u.role?.name ?? "—"} color={u.role?.name === "مدير النظام" ? "indigo" : "slate"} />
|
|
||||||
<StatusBadge active={u.isActive} />
|
|
||||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
|
||||||
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
|
||||||
{/* view */}
|
|
||||||
<IconBtn title={`عرض ${u.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(u)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
{/* edit */}
|
|
||||||
<IconBtn title={`تعديل ${u.name}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(u)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
{/* delete */}
|
|
||||||
<IconBtn title={`حذف ${u.name}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(u)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
{/* pagination */}
|
|
||||||
{pages > 1 && (
|
|
||||||
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
>
|
|
||||||
السابق
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
|
||||||
disabled={page === pages}
|
|
||||||
>
|
|
||||||
التالي
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -11,18 +11,7 @@ interface ArchivedUsersModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchIcon = (
|
const searchIcon = (
|
||||||
<svg
|
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {
|
export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {
|
||||||
|
|||||||
@@ -1,134 +1,79 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
|
||||||
|
import { ReusableTable, ActionButtons } from "../../UI";
|
||||||
|
import type { TableColumn } from "@/src/types/models";
|
||||||
import type { ArchivedUser } from "@/src/types/user";
|
import type { ArchivedUser } from "@/src/types/user";
|
||||||
|
|
||||||
// ── status badge ─────────────────────────────────────────────────────────────
|
|
||||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
|
||||||
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
function StatusBadge({ active }: { active: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
||||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
|
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
|
||||||
{active ? "نشط" : "معطل"}
|
{active ? "Active" : "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── card / header styles ─────────────────────────────────────────────────────
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Props ────────────────────────────────────────────────────────────────────
|
|
||||||
interface ArchivedUserTableProps {
|
interface ArchivedUserTableProps {
|
||||||
users: ArchivedUser[];
|
users: ArchivedUser[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
search: string;
|
search: string;
|
||||||
page: number;
|
page: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
onView: (user: ArchivedUser) => void;
|
onView: (user: ArchivedUser) => void;
|
||||||
onPageChange: (p: number) => void;
|
onPageChange: (p: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── main table ───────────────────────────────────────────────────────────────
|
|
||||||
export function ArchivedUserTable({ users, loading, search, page, pages, onView, onPageChange }: ArchivedUserTableProps) {
|
export function ArchivedUserTable({ users, loading, search, page, pages, onView, onPageChange }: ArchivedUserTableProps) {
|
||||||
|
const columns: TableColumn<ArchivedUser>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "الاسم",
|
||||||
|
width: "2fr",
|
||||||
|
render: (u) => (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p>
|
||||||
|
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "userName",
|
||||||
|
header: "الاسم المستخدم",
|
||||||
|
width: "1.5fr",
|
||||||
|
render: (u) => (
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "status", header: "الحالة", width: "1fr", render: (u) => <StatusBadge active={u.isActive} /> },
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
header: "تاريخ الانشاء",
|
||||||
|
width: "1fr",
|
||||||
|
align: "center",
|
||||||
|
render: (u) => new Date(u.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
header: "تاريخ التعديل",
|
||||||
|
width: "1fr",
|
||||||
|
align: "center",
|
||||||
|
render: (u) => new Date(u.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardStyle}>
|
<ReusableTable
|
||||||
{/* column headers */}
|
columns={columns}
|
||||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
|
data={users}
|
||||||
<span>الاسم</span>
|
loading={loading}
|
||||||
<span>اسم المستخدم</span>
|
search={search}
|
||||||
<span>الحالة</span>
|
page={page}
|
||||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
pages={pages}
|
||||||
<span style={{ textAlign: "center" }}>آخر تحديث</span>
|
onPageChange={onPageChange}
|
||||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
emptyIcon="🗄️"
|
||||||
</div>
|
emptyDescription={search ? `No results for "${search}"` : "No archived users."}
|
||||||
|
renderActions={(u) => <ActionButtons itemLabel={u.name} onView={() => onView(u)} />}
|
||||||
{/* loading state */}
|
/>
|
||||||
{loading ? (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
|
||||||
<Spinner size="sm" className="text-blue-600" />
|
|
||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
|
||||||
</div>
|
|
||||||
) : users.length === 0 ? (
|
|
||||||
/* empty state */
|
|
||||||
<EmptyState
|
|
||||||
icon="🗄️"
|
|
||||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون في الأرشيف."}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
/* data rows */
|
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
|
||||||
{users.map((u, i) => (
|
|
||||||
<li key={u.id} style={{
|
|
||||||
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px",
|
|
||||||
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
|
||||||
fontSize: 13,
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p>
|
|
||||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
|
|
||||||
<StatusBadge active={u.isActive} />
|
|
||||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
|
||||||
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
|
||||||
</span>
|
|
||||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
|
||||||
{new Date(u.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
|
||||||
{/* view only — delete/restore not implemented yet */}
|
|
||||||
<IconBtn title={`عرض ${u.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(u)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* pagination */}
|
|
||||||
{pages > 1 && (
|
|
||||||
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
>
|
|
||||||
السابق
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
|
||||||
disabled={page === pages}
|
|
||||||
>
|
|
||||||
التالي
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
136
src/Components/audit/AuditDetailsModel.tsx
Normal file
136
src/Components/audit/AuditDetailsModel.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { AuditLog } from "@/src/types/audit";
|
||||||
|
import { Button, Modal } from "../UI";
|
||||||
|
import { ActionBadge } from "./AuditTable";
|
||||||
|
|
||||||
|
interface AuditDetailModalProps {
|
||||||
|
log: AuditLog;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── small helper components ───────────────────────────────────────────────
|
||||||
|
// (Same rationale as UserDetailModal's: purpose-built layouts, not generic
|
||||||
|
// form/action controls, so they stay custom rather than pulled from the
|
||||||
|
// shared UI kit.)
|
||||||
|
|
||||||
|
function DetailRow({ label, value, mono }: { label: string; value?: string | null; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "flex", flexDirection: "column", gap: 4,
|
||||||
|
padding: "0.75rem 0",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 13, fontWeight: 500,
|
||||||
|
fontFamily: mono ? "var(--font-mono)" : undefined,
|
||||||
|
color: value ? "var(--color-text-primary)" : "var(--color-text-hint)",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}>
|
||||||
|
{value || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stands in for UserDetailModal's <Avatar/> — a log entry has no photo, so
|
||||||
|
// this is a colored icon circle instead of initials.
|
||||||
|
function ActionIcon() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 64, height: 64, borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg, #2563EB 0%, #7C3AED 100%)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: "0 4px 12px rgba(37,99,235,.3)",
|
||||||
|
}}>
|
||||||
|
<i className="ti ti-clipboard-check" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetadataBlock({ metadata }: { metadata: Record<string, unknown> }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, padding: "0.75rem 0" }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
|
||||||
|
بيانات إضافية
|
||||||
|
</span>
|
||||||
|
<pre style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: "0.75rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
direction: "ltr",
|
||||||
|
textAlign: "left",
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(metadata, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main component ──────────────────────────────────────────────────────
|
||||||
|
export function AuditDetailModal({ log, onClose }: AuditDetailModalProps) {
|
||||||
|
const fmt = (iso?: string | null) =>
|
||||||
|
iso ? new Date(iso).toLocaleString("ar-SA", { dateStyle: "long", timeStyle: "medium" }) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title="تفاصيل السجل"
|
||||||
|
subtitle="سجل التدقيق"
|
||||||
|
onClose={onClose}
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
|
إغلاق
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
|
||||||
|
|
||||||
|
{/* icon + action + timestamp row */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", gap: "1rem",
|
||||||
|
padding: "0 0 1.25rem",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
}}>
|
||||||
|
<ActionIcon />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
|
{log.module ?? "سجل تدقيق"}
|
||||||
|
</p>
|
||||||
|
<p style={{ marginTop: 3, fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||||
|
{fmt(log.createdAt)}
|
||||||
|
</p>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<ActionBadge action={log.action} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* detail rows */}
|
||||||
|
<DetailRow label="المعرّف" value={log.entityId} mono />
|
||||||
|
<DetailRow label="المستخدم" value={log.userName ?? log.userId} />
|
||||||
|
<DetailRow label="عنوان IP" value={log.ipAddress} mono />
|
||||||
|
<DetailRow label="متصفح المستخدم" value={log.userAgent} mono />
|
||||||
|
|
||||||
|
{log.metadata && Object.keys(log.metadata).length > 0 && (
|
||||||
|
<MetadataBlock metadata={log.metadata} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/Components/audit/AuditTable.tsx
Normal file
129
src/Components/audit/AuditTable.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
import { ReusableTable } from "../UI";
|
||||||
|
import type { TableColumn } from "@/src/types/models";
|
||||||
|
import type { AuditLog } from "@/src/types/audit";
|
||||||
|
|
||||||
|
// ── Action badge — moved verbatim from app/dashboard/audit/page.tsx ─────────
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<
|
||||||
|
string,
|
||||||
|
{ bg: string; color: string; border: string }
|
||||||
|
> = {
|
||||||
|
CREATE: { bg: "#DCFCE7", color: "#166534", border: "#BBF7D0" },
|
||||||
|
UPDATE: { bg: "#EFF6FF", color: "#1D4ED8", border: "#BFDBFE" },
|
||||||
|
DELETE: { bg: "#FEF2F2", color: "#DC2626", border: "#FECACA" },
|
||||||
|
LOGIN: { bg: "#F5F3FF", color: "#5B21B6", border: "#DDD6FE" },
|
||||||
|
LOGOUT: { bg: "#F1F5F9", color: "#475569", border: "#E2E8F0" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exported (not just used locally) so AuditDetailModal can render the same
|
||||||
|
// badge in its header instead of re-implementing the color mapping.
|
||||||
|
export function ActionBadge({ action }: { action: string }) {
|
||||||
|
const upper = action?.toUpperCase() ?? "";
|
||||||
|
const key =
|
||||||
|
Object.keys(ACTION_COLORS).find((k) => upper.includes(k)) ?? "CREATE";
|
||||||
|
const cfg = ACTION_COLORS[key];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
border: `1px solid ${cfg.border}`,
|
||||||
|
background: cfg.bg,
|
||||||
|
padding: "0.2rem 0.625rem",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: cfg.color,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString("ar-SA", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditTableProps {
|
||||||
|
logs: AuditLog[];
|
||||||
|
loading: boolean;
|
||||||
|
search: string;
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
onPageChange: (p: number) => void;
|
||||||
|
onRowClick: (log: AuditLog) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditTable({
|
||||||
|
logs, loading, search, page, pages,
|
||||||
|
onPageChange, onRowClick,
|
||||||
|
}: AuditTableProps) {
|
||||||
|
const columns: TableColumn<AuditLog>[] = [
|
||||||
|
{
|
||||||
|
key: "action",
|
||||||
|
header: "الإجراء",
|
||||||
|
width: "1.5fr",
|
||||||
|
render: (log) => (
|
||||||
|
<div>
|
||||||
|
<ActionBadge action={log.action} />
|
||||||
|
{log.entityId && (
|
||||||
|
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||||
|
{log.entityId.slice(0, 12)}…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "module", header: "الوحدة", width: "1fr", render: (log) => log.module ?? "—" },
|
||||||
|
{ key: "user", header: "المستخدم", width: "1fr", render: (log) => log.userName ?? log.userId ?? "—" },
|
||||||
|
{
|
||||||
|
key: "ipAddress",
|
||||||
|
header: "عنوان IP",
|
||||||
|
width: "1.5fr",
|
||||||
|
render: (log) => (
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||||
|
{log.ipAddress ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
header: "الوقت",
|
||||||
|
width: "1fr",
|
||||||
|
render: (log) => (
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
{fmtDateTime(log.createdAt)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Now showing ALL matching logs in one request (see the page's
|
||||||
|
// loadLogs), so instead of paging through them, the list scrolls
|
||||||
|
// internally within a fixed max-height. `pages` will naturally stay
|
||||||
|
// at 1 for a normal-sized result set, so ReusableTable's own pager
|
||||||
|
// stays hidden; this wrapper is what actually gives the scroll.
|
||||||
|
<div style={{ maxHeight: "70vh", overflowY: "auto", borderRadius: "var(--radius-xl)" }}>
|
||||||
|
<ReusableTable
|
||||||
|
columns={columns}
|
||||||
|
data={logs}
|
||||||
|
loading={loading}
|
||||||
|
search={search}
|
||||||
|
page={page}
|
||||||
|
pages={pages}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
emptyDescription={search ? `لا نتائج لـ "${search}"` : "لا توجد سجلات مطابقة"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
|||||||
import { Alert, Button, Input } from "@/src/Components/UI";
|
import { Alert, Button, Input } from "@/src/Components/UI";
|
||||||
import { useAuth } from "@/src/hooks/useAuth";
|
import { useAuth } from "@/src/hooks/useAuth";
|
||||||
import Logo from "@/src/utils/logo";
|
import Logo from "@/src/utils/logo";
|
||||||
import { loginSchema, type LoginFormData } from "@/src/validations/auth.validator";
|
import {
|
||||||
|
loginSchema,
|
||||||
|
type LoginFormData,
|
||||||
|
} from "@/src/validations/auth.validator";
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const { login, loading, error, clearError } = useAuth();
|
const { login, loading, error, clearError } = useAuth();
|
||||||
@@ -25,47 +28,212 @@ export function LoginForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ minHeight: "100vh", background: "var(--color-surface-muted)", color: "var(--color-text-primary)" }}>
|
<main
|
||||||
<section style={{ minHeight: "100vh", width: "100%", display: "grid", gridTemplateColumns: "1fr 1fr" }}>
|
style={{
|
||||||
<aside style={{ position: "relative", overflow: "hidden", background: "linear-gradient(135deg, #0f172a 0%, #2563EB 50%, #3b82f6 100%)", color: "#FFF", padding: "2.5rem 3rem", display: "flex", flexDirection: "column", justifyContent: "space-between" }}>
|
minHeight: "100vh",
|
||||||
<div aria-hidden="true" style={{ position: "absolute", inset: 0, opacity: 0.28, backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)", backgroundSize: "40px 40px" }} />
|
background: "var(--color-surface-muted)",
|
||||||
<div style={{ position: "relative", zIndex: 1, display: "flex", flexDirection: "column", justifyContent: "space-between", height: "100%" }}>
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
width: "100%",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, #0f172a 0%, #2563EB 50%, #3b82f6 100%)",
|
||||||
|
color: "#FFF",
|
||||||
|
padding: "2.5rem 3rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
opacity: 0.28,
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)",
|
||||||
|
backgroundSize: "40px 40px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<Logo white={true} />
|
<Logo white={true} href="/" />
|
||||||
<h1 style={{ marginTop: "2.5rem", maxWidth: "28rem", fontSize: "2.25rem", fontWeight: 700, lineHeight: 1.2, letterSpacing: "-0.02em" }}>
|
<h1
|
||||||
|
style={{
|
||||||
|
marginTop: "2.5rem",
|
||||||
|
maxWidth: "28rem",
|
||||||
|
fontSize: "2.25rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
اللوجستيات ببساطة، والتسليم بثقة.
|
اللوجستيات ببساطة، والتسليم بثقة.
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ marginTop: "1rem", maxWidth: "28rem", fontSize: "0.95rem", lineHeight: 1.8, color: "rgba(255,255,255,0.75)" }}>
|
<p
|
||||||
راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب من خلال تسجيل دخول آمن واحد.
|
style={{
|
||||||
|
marginTop: "1rem",
|
||||||
|
maxWidth: "28rem",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
lineHeight: 1.8,
|
||||||
|
color: "rgba(255,255,255,0.75)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب
|
||||||
|
من خلال تسجيل دخول آمن واحد.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ marginTop: "2rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "2rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{[
|
{[
|
||||||
["التنسيق اللحظي", "تتبع كل طلب من الاستلام إلى التسليم في مكان واحد."],
|
[
|
||||||
["وصول آمن", "الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة."],
|
"التنسيق اللحظي",
|
||||||
["عمليات سريعة", "استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات."],
|
"تتبع كل طلب من الاستلام إلى التسليم في مكان واحد.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"وصول آمن",
|
||||||
|
"الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"عمليات سريعة",
|
||||||
|
"استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات.",
|
||||||
|
],
|
||||||
].map(([title, desc]) => (
|
].map(([title, desc]) => (
|
||||||
<article key={title} style={{ display: "flex", alignItems: "flex-start", gap: "0.75rem", borderRadius: "0.75rem", border: "1px solid rgba(255,255,255,0.15)", background: "rgba(255,255,255,0.1)", padding: "0.85rem" }}>
|
<article
|
||||||
<div aria-hidden="true" style={{ marginTop: "0.125rem", flexShrink: 0, width: "2.25rem", height: "2.25rem", borderRadius: "0.6rem", background: "rgba(255,255,255,0.15)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.95rem" }}>
|
key={title}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: "0.75rem",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
border: "1px solid rgba(255,255,255,0.15)",
|
||||||
|
background: "rgba(255,255,255,0.1)",
|
||||||
|
padding: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
marginTop: "0.125rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
width: "2.25rem",
|
||||||
|
height: "2.25rem",
|
||||||
|
borderRadius: "0.6rem",
|
||||||
|
background: "rgba(255,255,255,0.15)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
•
|
•
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ fontSize: "0.9rem", fontWeight: 600, margin: 0 }}>{title}</h2>
|
<h2
|
||||||
<p style={{ marginTop: "0.25rem", fontSize: "0.8rem", color: "rgba(255,255,255,0.8)" }}>{desc}</p>
|
style={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: "0.75rem", color: "rgba(255,255,255,0.5)" }}>مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.</p>
|
<p style={{ fontSize: "0.75rem", color: "rgba(255,255,255,0.5)" }}>
|
||||||
|
مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section style={{ display: "flex", alignItems: "center", justifyContent: "center", background: "var(--color-surface)", padding: "2.5rem 1.5rem" }}>
|
<section
|
||||||
<form onSubmit={handleSubmit(onSubmit)} style={{ width: "100%", maxWidth: "26rem", borderRadius: "0.9rem", border: "1px solid var(--color-border)", background: "#FFF", padding: "2rem", boxShadow: "var(--shadow-card)" }} noValidate>
|
style={{
|
||||||
<p style={{ fontSize: "1.75rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>مرحبًا بعودتك</p>
|
display: "flex",
|
||||||
<p style={{ marginTop: "0.35rem", fontSize: "0.9rem", color: "var(--color-text-muted)" }}>سجل الدخول لإدارة عمليات التسليم</p>
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
padding: "2.5rem 1.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "26rem",
|
||||||
|
borderRadius: "0.9rem",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "#FFF",
|
||||||
|
padding: "2rem",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
}}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "1.75rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
مرحبًا بعودتك
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: "0.35rem",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
سجل الدخول لإدارة عمليات التسليم
|
||||||
|
</p>
|
||||||
|
|
||||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
label="البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم"
|
label="البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
@@ -84,8 +252,23 @@ export function LoginForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: "0.75rem", display: "flex", justifyContent: "flex-end" }}>
|
<div
|
||||||
<Link href="/forgot-password" style={{ fontSize: "0.8rem", color: "var(--color-brand-600)", textDecoration: "none" }}>
|
style={{
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--color-brand-600)",
|
||||||
|
textDecoration: "none",
|
||||||
|
pointerEvents: "none",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
هل نسيت كلمة المرور؟
|
هل نسيت كلمة المرور؟
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,13 +279,35 @@ export function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" loading={loading} fullWidth className="mt-6 h-11">
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
fullWidth
|
||||||
|
className="mt-6 h-11"
|
||||||
|
>
|
||||||
{loading ? "جاري تسجيل الدخول…" : "تسجيل الدخول"}
|
{loading ? "جاري تسجيل الدخول…" : "تسجيل الدخول"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p style={{ marginTop: "1rem", textAlign: "center", fontSize: "0.85rem", color: "var(--color-text-muted)" }}>
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
ليس لديك حساب؟{" "}
|
ليس لديك حساب؟{" "}
|
||||||
<Link href="/register" style={{ color: "var(--color-brand-600)", textDecoration: "none" }}>
|
<Link
|
||||||
|
href="/register"
|
||||||
|
style={{
|
||||||
|
color: "var(--color-brand-600)",
|
||||||
|
textDecoration: "none",
|
||||||
|
pointerEvents: "none",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
aria-disabled="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
إنشاء حساب
|
إنشاء حساب
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
86
src/Components/car/CarDeleteModal.tsx
Normal file
86
src/Components/car/CarDeleteModal.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import type { Car } from "@/src/types/car";
|
||||||
|
|
||||||
|
interface CarDeleteModalProps {
|
||||||
|
car: Car;
|
||||||
|
deleting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CarDeleteModal({ car, deleting, onCancel, onConfirm }: CarDeleteModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alertdialog" aria-modal="true" aria-labelledby="car-del-title"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 60,
|
||||||
|
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%", maxWidth: 420,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
||||||
|
padding: "2rem",
|
||||||
|
display: "flex", flexDirection: "column", gap: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 id="car-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
|
حذف المركبة
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
||||||
|
هل أنت متأكد من حذف{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{car.manufacturer} {car.model}
|
||||||
|
</strong>
|
||||||
|
{" "}(
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||||
|
{car.plateLetters} {car.plateNumber}
|
||||||
|
</span>
|
||||||
|
)؟ لا يمكن التراجع عن هذا الإجراء.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button type="button" onClick={onCancel} disabled={deleting}
|
||||||
|
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onConfirm} disabled={deleting}
|
||||||
|
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
|
||||||
|
{deleting && <Spinner size="sm" className="text-white" />}
|
||||||
|
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -229,11 +229,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
|||||||
{/* Row 1: secondary views */}
|
{/* Row 1: secondary views */}
|
||||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||||
<Button type="button" variant="secondary" style={{ flex: 1 }} onClick={() => setGallery(true)}>
|
<Button type="button" variant="secondary" style={{ flex: 1 }} onClick={() => setGallery(true)}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<i className="ti ti-photo" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
||||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
||||||
<polyline points="21 15 16 10 5 21" />
|
|
||||||
</svg>
|
|
||||||
الصور
|
الصور
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -242,9 +238,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
|||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
onClick={() => router.push(`/dashboard/cars/${car.id}/maintenance`)}
|
onClick={() => router.push(`/dashboard/cars/${car.id}/maintenance`)}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<i className="ti ti-tool" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
|
||||||
</svg>
|
|
||||||
عرض تاريخ الصيانة
|
عرض تاريخ الصيانة
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,10 +246,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
|||||||
{/* Row 2: primary edit / delete */}
|
{/* Row 2: primary edit / delete */}
|
||||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||||
<Button type="button" variant="primary" style={{ flex: 2 }} onClick={() => onEdit(car)}>
|
<Button type="button" variant="primary" style={{ flex: 2 }} onClick={() => onEdit(car)}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
تعديل
|
تعديل
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="danger" onClick={() => onDelete(car)}>
|
<Button type="button" variant="danger" onClick={() => onDelete(car)}>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Button, Input, Modal, Select } from "../UI";
|
import { Alert, Button, Input, Modal, Select } from "../UI";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
|
import { useEditFormSync } from "@/src/hooks/useEditFormSync";
|
||||||
import {
|
import {
|
||||||
createCarSchema,
|
createCarSchema,
|
||||||
updateCarSchema,
|
updateCarSchema,
|
||||||
@@ -134,38 +135,31 @@ export function CarFormModal({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
|
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
|
||||||
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
||||||
const loadBranches = useCallback(() => {
|
const loadBranches = useCallback(() => {
|
||||||
// defaultValues.branchId was applied before this list existed, so the
|
if (branchesProp.length > 0) {
|
||||||
// <select> had no matching <option> yet and silently fell back to "" —
|
queueMicrotask(() => setBranches(branchesProp));
|
||||||
// reapply the saved id now that the option actually exists in the DOM.
|
return;
|
||||||
const savedBranchId = editCar?.branch?.id;
|
}
|
||||||
|
const token = getStoredToken();
|
||||||
|
branchService
|
||||||
|
.getOptions(token)
|
||||||
|
.then((list) => {
|
||||||
|
queueMicrotask(() => setBranches(list as unknown as Branch[]));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* silently ignore */
|
||||||
|
});
|
||||||
|
}, [branchesProp]);
|
||||||
|
|
||||||
if (branchesProp.length > 0) {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
loadBranches();
|
||||||
setBranches(branchesProp);
|
}, [loadBranches]);
|
||||||
if (savedBranchId) setValue("branchId", savedBranchId);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const token = getStoredToken();
|
|
||||||
branchService
|
|
||||||
.getOptions(token)
|
|
||||||
.then((list) => {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
setBranches(list as unknown as Branch[]);
|
|
||||||
if (savedBranchId) setValue("branchId", savedBranchId);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
/* silently ignore */
|
|
||||||
});
|
|
||||||
}, [branchesProp, editCar, setValue]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// CHANGE: replaced the manual `if (savedBranchId) setValue("branchId", ...)`
|
||||||
loadBranches();
|
// calls above with useEditFormSync.
|
||||||
}, [loadBranches]);
|
useEditFormSync(setValue, "branchId", editCar?.branch?.id, branches);
|
||||||
|
|
||||||
const [apiError, setApiError] = useState("");
|
const [apiError, setApiError] = useState("");
|
||||||
|
|
||||||
|
|||||||
@@ -120,9 +120,7 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
|||||||
loading={uploading}
|
loading={uploading}
|
||||||
>
|
>
|
||||||
{!uploading && (
|
{!uploading && (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<i className="ti ti-upload" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
{uploading ? "جارٍ الرفع…" : "رفع صور"}
|
{uploading ? "جارٍ الرفع…" : "رفع صور"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -224,10 +222,7 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
|||||||
>
|
>
|
||||||
{isDeleting
|
{isDeleting
|
||||||
? <Spinner size="sm" className="text-red-600" />
|
? <Spinner size="sm" className="text-red-600" />
|
||||||
: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
: <i className="ti ti-trash" style={{ fontSize: 12 }} aria-hidden="true" />
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
</svg>
|
|
||||||
}
|
}
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,9 +48,7 @@ export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) {
|
|||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
icon={
|
icon={
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user