Compare commits

..

16 Commits

Author SHA1 Message Date
m7amedez5511
1e4d9c5b59 update dashboard home 2026-08-30 16:18:54 +03:00
m7amedez5511
0658e59d9b edit location info 2026-08-23 15:34:18 +03:00
m7amedez5511
b27da3f6e8 solve middleware problem and navigation problem 2026-08-23 11:34:42 +03:00
m7amedez5511
c5e4ade2fb solve Hydration Mismatch problem 2026-08-19 18:34:03 +03:00
m7amedez5511
2d05d3446c solve problem tree rerender 2026-08-19 18:00:55 +03:00
m7amedez5511
55b03ad6f4 update user page and logo to be link also update profile page 2026-08-17 16:24:58 +03:00
m7amedez5511
17dc53fcfe build dashboard home page and dashboard layer 2026-07-27 17:54:43 +03:00
m7amedez5511
0befda9690 Replace SVGs with icons, restructure the table for CRUD operations across the project, standardize the application's display, and build a new template for the table's action buttons. 2026-07-26 20:11:31 +03:00
m7amedez5511
a1a34cb251 Replace SVGs with icons, restructure the table for CRUD operations across the project, standardize the application's display, and build a new template for the table's action buttons. 2026-07-26 20:10:12 +03:00
m7amedez5511
b2dcb45606 The trip files have been reorganized. Errors related to vehicle and driver editing have been resolved, archive display issues fixed, and model data display standardized across the project; order and trip-related issues have also been addressed. 2026-07-23 15:49:24 +03:00
m7amedez511
b6dc2c709e update رAddressDetailModal 2026-07-23 13:52:30 +03:00
m7amedez511
e42252db47 cheange inlien code to block code and using tamplet 2026-07-22 17:09:15 +03:00
m7amedez511
fa49ba8817 The client files, client address files, and order files have been modified to implement all CRUD operations and endpoints related to the archive. 2026-07-22 16:30:20 +03:00
m7amedez5511
b87d81100f The trip files have been reorganized. Errors related to vehicle and driver editing have been resolved, archive display issues fixed, and model data display standardized across the project; order and trip-related issues have also been addressed. 2026-07-22 14:19:32 +03:00
m7amedez5511
f1a521dd5a refactor(forms): migrate form modals to react-hook-form + yupResolver, and adopt shared UI kit components across Role/Order/User/Trip modals and tables
Replace manual useState + validate() wiring with react-hook-form and
yupResolver across CarFormModal, DriverFormModal, Tripformmodal,
OrderFormModal, BranchFormModal, CarMaintananceFormModal, RoleFormModal,
and UserFormModal, following the existing pattern in Addressformmodal.

- Pin yupResolver's generic explicitly (yupResolver<FormValues>(schema as any))
  since create/update schemas differ structurally and yup can't unify them
  into a single inferred type on its own.
- Replace manual ref-based autofocus (useRef + ref={firstRef}) with
  autoFocus/setFocus to avoid ref collisions with register()'s own ref.
- DriverFormModal: bind photo/nationalPhoto/driverCardPhoto file fields via
  Controller instead of register.
- Tripformmodal: convert startTime/endTime to ISO-8601 via setValueAs while
  keeping the datetime-local input display unaffected.
- OrderFormModal: register nested deliveryAddress/pickupAddress fields via
  dot-path syntax; keep pickupMode as local state; preserve conditional
  address payload assembly.
- CarMaintananceFormModal: keep toNumberOrUndefined cost coercion and
  ISO-8601 date conversion at submit time.
- RoleFormModal: bind permissionIds checkbox group via Controller.
- UserFormModal: add a custom resolver wrapper to preserve the
  empty-password-on-edit behavior (skip password validation when editing
  with a blank password field).

Also replace raw HTML elements with the shared UI kit components
(Modal, Input, Textarea, Select, Button, Alert, Badge, EmptyState) across
form modals, detail modals, and tables:

- RoleFormModal: Modal, Input, Textarea, Button
- OrderFormModal: Modal, Input, Select, Button
- UserDetailModal / Archiveduserdetailmodal: Modal, Alert, Button
- UserTable / Archivedusertable: Badge, Button, EmptyState
- RoleDetailModal / ArchivedRoleDetailModal: Modal, Alert, Select, Button
- RoleTable / ArchivedRoleTable: Button, EmptyState
- Tripreportpanel: Button with built-in loading state instead of manual
  Spinner/disabled/color wiring

StatusBadge, IconBtn, and other per-action custom chips/checkboxes are kept
as-is in every file — the shared Badge/Button components don't support dot
indicators or per-action color-coding, so forcing them in would misshape or
strip semantics from these controls.

Archivedusersmodal, ArchivedRolesModal, and ArchivedTripList required no
changes — already fully compliant with the shared UI kit.

No changes to visual layout, CSS, or Arabic labels/section headings beyond
what's required by the component swaps above.
2026-07-21 13:15:22 +03:00
m7amedez5511
55f76005c9 update page to using same tamplet in all project not using inline code 2026-07-20 16:03:07 +03:00
149 changed files with 12473 additions and 10451 deletions

101
README.md
View File

@@ -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

View File

@@ -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 "";
} }

View File

@@ -146,7 +146,20 @@ const GRAD = "linear-gradient(175deg, #1E3A8A 0%, #1D4ED8 60%, #1565C0 100%)";
══════════════════════════════════════════ */ ══════════════════════════════════════════ */
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const user = getStoredUser();
// ⚠️ التقاط اليوزر بعد الـ mount بس، عشان أول render على الكلاينت
// يتطابق مع السيرفر (فاضي) ويتجنب hydration mismatch
const [user, setUser] = useState<ReturnType<typeof getStoredUser>>(null);
useEffect(() => {
setUser(getStoredUser());
}, []);
// إعادة القراءة بعد أي navigation (زي بعد الحفظ لو الصلاحيات اتغيرت)
useEffect(() => {
setUser(getStoredUser());
}, [pathname]);
const permissions = user?.permissions ?? []; const permissions = user?.permissions ?? [];
const { open, setOpen } = useSidebarDrawer(); const { open, setOpen } = useSidebarDrawer();
@@ -275,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
@@ -322,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
@@ -363,7 +366,6 @@ function SidebarContent({
pathname.startsWith(item.href + "/")); pathname.startsWith(item.href + "/"));
return ( return (
<Link <Link
suppressHydrationWarning
key={item.href} key={item.href}
href={item.href} href={item.href}
title={compact ? item.label : undefined} title={compact ? item.label : undefined}

View File

@@ -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() {

View 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;
}

View File

@@ -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)}
/>
)}
</>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
} }

View File

@@ -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,
}}
>
اضغط على &quot;إضافة مركبة&quot; لإضافة أول مركبة في الأسطول. اضغط على &quot;إضافة مركبة&quot; لإضافة أول مركبة في الأسطول.
</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)",

View File

@@ -3,7 +3,16 @@
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 { Alert, ConfirmDialog, Toast, ArchiveButton } from "@/src/Components/UI"; import {
Alert,
ConfirmDialog,
Toast,
ArchiveButton,
Button,
EmptyState,
PageLoader,
InlineLoader,
} 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";
@@ -13,6 +22,7 @@ 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,
@@ -38,16 +48,21 @@ function labelIcon(label: string): string {
} }
// ─── 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;
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({ address, onEdit, onDelete, onSetPrimary, settingPrimary }: AddressCardProps) { function AddressCard({ address, onView, onEdit, onDelete, onSetPrimary, settingPrimary }: AddressCardProps) {
const { details, contactPerson } = address; const { details, contactPerson } = address;
const { const {
street, street,
@@ -63,6 +78,10 @@ function AddressCard({ address, onEdit, onDelete, onSetPrimary, settingPrimary }
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",
@@ -73,6 +92,7 @@ function AddressCard({ address, onEdit, onDelete, onSetPrimary, settingPrimary }
padding: "1.25rem", padding: "1.25rem",
background: "var(--color-surface)", background: "var(--color-surface)",
boxShadow: "var(--shadow-card)", boxShadow: "var(--shadow-card)",
cursor: "pointer",
}} }}
> >
{/* Label row */} {/* Label row */}
@@ -183,8 +203,9 @@ function AddressCard({ address, onEdit, onDelete, onSetPrimary, settingPrimary }
</div> </div>
)} )}
{/* Actions */} {/* Actions — stopPropagation so clicking them doesn't open the detail modal */}
<div <div
onClick={(e) => e.stopPropagation()}
style={{ style={{
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
@@ -194,124 +215,31 @@ function AddressCard({ address, onEdit, onDelete, onSetPrimary, settingPrimary }
borderTop: "1px solid var(--color-border)", borderTop: "1px solid var(--color-border)",
}} }}
> >
<ActionBtn onClick={onEdit} color="#1D4ED8" bg="#EFF6FF" border="#BFDBFE"> <Button type="button" variant="secondary" size="sm" onClick={onEdit}>
تعديل تعديل
</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>
); );
@@ -341,7 +269,7 @@ export default function ClientAddressesPage() {
setClientLoading(true); setClientLoading(true);
clientService clientService
.getById(clientId, getStoredToken()) .getById(clientId, getStoredToken())
.then((res) => setClient(res.data)) .then((res) => setClient(res))
.catch(() => router.replace("/dashboard/clients")) .catch(() => router.replace("/dashboard/clients"))
.finally(() => setClientLoading(false)); .finally(() => setClientLoading(false));
}); });
@@ -372,6 +300,8 @@ export default function ClientAddressesPage() {
const [editingClient, setEditingClient] = 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 (
@@ -403,7 +333,7 @@ export default function ClientAddressesPage() {
if (!client) return false; if (!client) return false;
try { try {
const res = await clientService.update(client.id, data, getStoredToken()); const res = await clientService.update(client.id, data, getStoredToken());
setClient(res.data); setClient(res);
return true; return true;
} catch { } catch {
return false; return false;
@@ -411,21 +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 ────────────────────────────────────────────────────────────────
@@ -433,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
@@ -448,14 +374,20 @@ export default function ClientAddressesPage() {
loading={deleting} loading={deleting}
onCancel={() => { if (!deleting) setDeleteTarget(null); }} onCancel={() => { 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}
/> />
@@ -481,28 +413,17 @@ 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 width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-chevron-left" style={{ fontSize: 14 }} aria-hidden="true" />
<path d="M15 18l-6-6 6-6" />
</svg>
جميع العملاء جميع العملاء
</button> </Button>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}> <p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة العناوين إدارة العناوين
@@ -521,61 +442,19 @@ export default function ClientAddressesPage() {
</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>
@@ -620,46 +499,20 @@ export default function ClientAddressesPage() {
{/* ── Address grid ── */} {/* ── Address grid ── */}
{addrLoading ? ( {addrLoading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "4rem 0", color: "var(--color-text-muted)", gap: 12, fontSize: 13 }}> // CHANGE: hand-rolled spinning <div> → InlineLoader
<div <InlineLoader message="جارٍ تحميل العناوين…" />
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={{
@@ -672,6 +525,7 @@ export default function ClientAddressesPage() {
<AddressCard <AddressCard
key={addr.id} key={addr.id}
address={addr} address={addr}
onView={() => setViewAddress(addr)}
onEdit={() => setAddrFormTarget(addr)} onEdit={() => setAddrFormTarget(addr)}
onDelete={() => setDeleteTarget(addr)} onDelete={() => setDeleteTarget(addr)}
onSetPrimary={() => handleSetPrimary(addr)} onSetPrimary={() => handleSetPrimary(addr)}

View File

@@ -131,7 +131,7 @@ export default function ClientsPage() {
</section> </section>
{/* Floating button to open the archive browser */} {/* Floating button to open the archive browser */}
<ArchiveButton onClick={() => setArchiveOpen(true)} label="أرشيف العملاء" /> <ArchiveButton onClick={() => setArchiveOpen(true)} label="الأرشيف" />
</> </>
); );
} }

View File

@@ -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)}

View File

@@ -1,73 +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,
} 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 ────────────────────────────────────────────────────────────
@@ -159,19 +102,146 @@ 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 ── */}
{notification && ( {notification && (
@@ -180,298 +250,17 @@ export default function DriversPage() {
{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)}

View File

@@ -3,64 +3,13 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { Alert, Spinner, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI"; import { Alert, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
import { ORDER_STATUS_MAP } from "@/src/Components/Order/OrderDetailBanel"; 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} from "@/src/Components/Order"; import { OrderFormModal , OrderDetailPanel} from "@/src/Components/Order";
import Header from "@/src/Components/UI/Header";
// ── Helpers — copied verbatim from app/dashboard/drivers/page.tsx ───────
function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric", month: "short", day: "numeric",
});
}
function fmtAmount(n?: number | null): string {
if (n == null) return "—";
return `${n.toFixed(2)} ر.س`;
}
// ── 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,
};
// ── Page Component ─────────────────────────────────────────────────────── // ── Page Component ───────────────────────────────────────────────────────
@@ -143,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
@@ -167,171 +195,17 @@ export default function OrderComponent() {
)} )}
{/* ── Table ── */} {/* ── Table ── */}
<div style={cardStyle}> <OrderTable
<div orders={orders}
dir="rtl" loading={loading}
style={{ search={search}
display: "grid", page={page}
gridTemplateColumns: ROW_GRID_COLUMNS, pages={pages}
...thStyle, setPage={setPage}
}} onRowClick={(orderId) => setSelectedOrderId(orderId)}
> onEdit={handleEdit}
<span>رقم الشحنة</span> onDelete={handleDelete}
<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>
) : orders.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 }}>
{orders.map((o, i) => {
const statusCfg = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
return (
<li
key={o.id}
onClick={() => setSelectedOrderId(o.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, 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>
</div>
<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={{ color: "var(--color-text-secondary)" }}>{o.client?.name ?? "—"}</span>
<span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}>
{fmtAmount(o.totalPrice)}
</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(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(); handleDelete(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>
<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 a successful edit or {/* ── Detail panel — key forces re-fetch after a successful edit or

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -0,0 +1,7 @@
import React from 'react'
export default function page() {
return (
<div>page</div>
)
}

View File

@@ -2,12 +2,16 @@
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/Tripformmodal"; 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";
@@ -20,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;
@@ -117,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
@@ -160,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>
@@ -177,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) => {
@@ -201,46 +233,58 @@ 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 () => {
if (!tripId) return; if (!tripId) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const token = getStoredToken(); const token = getStoredToken();
const data = await tripService.getById(tripId, token); const data = await tripService.getById(tripId, token);
setTrip(data); setTrip(data);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات الرحلة."); setError(extractApiMessage(err, "تعذّر تحميل بيانات الرحلة."));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [tripId]); }, [tripId]);
useEffect(() => { useEffect(() => {
queueMicrotask(loadTrip); queueMicrotask(loadTrip);
}, [loadTrip]); }, [loadTrip]);
// ── Edit submit ─────────────────────────────────────────────────────────── // ── Edit submit ───────────────────────────────────────────────────────────
const handleEditSubmit = useCallback( const handleEditSubmit = useCallback(
async ( async (
payload: CreateTripPayload | UpdateTripPayload, payload: CreateTripPayload | UpdateTripPayload,
): Promise<boolean> => { ): Promise<boolean> => {
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(
setTrip(updated); trip.id,
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." }); payload as UpdateTripPayload,
return true; token,
} catch (err) { );
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") }); setTrip(updated);
return false; notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
} return true;
}, } catch (err) {
[trip, notify], notify({
); type: "error",
message: extractApiMessage(err, "تعذّر تحديث الرحلة."),
});
return false;
}
},
[trip, notify],
);
// ── Delete confirm ──────────────────────────────────────────────────────── // ── Delete confirm ────────────────────────────────────────────────────────
const handleConfirmDelete = useCallback(async () => { const handleConfirmDelete = useCallback(async () => {
@@ -251,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]);
@@ -295,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>
); );
} }
@@ -324,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={{
@@ -337,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
@@ -378,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 && (
@@ -409,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>
)} )}
@@ -456,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>
@@ -467,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>
@@ -488,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>
@@ -542,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}

View File

@@ -1,110 +1,19 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { ArchivedTripDetailModal } from "@/src/Components/Trip/archive/ArchivedtripDetailModal";
import { useParams, useRouter } from "next/navigation";
import { Spinner, EmptyState, Badge, PageHeader } from "@/src/Components/UI";
import { tripService } from "@/src/services/trip.service";
import { getStoredToken } from "@/src/lib/auth";
import type { Trip } from "@/src/types/trip";
import { TRIP_STATUS_MAP } from "@/src/types/trip";
function fmtDateTime(iso?: string | null): string {
if (!iso) return "—"; import { useParams, useRouter } from "next/navigation";
return new Date(iso).toLocaleString("ar-SA", { dateStyle: "medium", timeStyle: "short" });
}
export default function ArchivedTripDetailPage() { export default function ArchivedTripDetailPage() {
const params = useParams(); const params = useParams();
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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!tripId) return;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const token = getStoredToken();
const data = await tripService.getArchivedById(tripId, token);
if (!cancelled) setTrip(data);
} catch {
if (!cancelled) setError("لم يتم العثور على هذه الرحلة في الأرشيف.");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [tripId]);
if (loading) {
return (
<div className="flex items-center justify-center gap-3 py-24 text-[var(--color-text-muted)]">
<Spinner size="sm" />
<span className="text-sm">جارٍ التحميل</span>
</div>
);
}
if (error || !trip) {
return (
<EmptyState
icon="🗄️"
title="الرحلة غير موجودة في الأرشيف"
description={error ?? "تعذّر العثور على رحلة بهذا المعرّف."}
action={
<button
onClick={() => router.push("/dashboard/trips")}
className="text-sm font-semibold text-[var(--color-brand-600)] underline"
>
العودة إلى قائمة الرحلات
</button>
}
/>
);
}
const statusConfig = TRIP_STATUS_MAP[trip.status];
return ( return (
<section className="flex flex-col gap-6 p-6" dir="rtl"> <ArchivedTripDetailModal
<PageHeader tripId={tripId}
title={trip.title} onClose={() => router.push("/dashboard/trips")}
description={`رقم الرحلة: ${trip.tripNumber}`} />
backHref="/dashboard/trips"
backLabel="الرحلات"
action={<Badge label="مؤرشفة" color="amber" />}
/>
<div className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-[var(--shadow-card)]">
<Badge label={statusConfig.label} color="slate" />
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm">
<div>
<dt className="text-[var(--color-text-muted)]">وقت البدء</dt>
<dd>{fmtDateTime(trip.startTime)}</dd>
</div>
<div>
<dt className="text-[var(--color-text-muted)]">وقت الانتهاء</dt>
<dd>{fmtDateTime(trip.endTime)}</dd>
</div>
<div>
<dt className="text-[var(--color-text-muted)]">تاريخ الأرشفة</dt>
<dd>{fmtDateTime(trip.deletedAt)}</dd>
</div>
{trip.driver && (
<div>
<dt className="text-[var(--color-text-muted)]">السائق</dt>
<dd>{trip.driver.name}</dd>
</div>
)}
</dl>
</div>
</section>
); );
} }

View File

@@ -1,164 +1,40 @@
"use client"; "use client";
// app/dashboard/trips/page.tsx
// CHANGE: replaced the old hand-rolled inline table with <TripTable/>.
// 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: removed the local ArchivedTripsModal wrapper (it duplicated what
// the new ArchivedTripModal already does — search bar + table + detail) and
// removed the ArchivedTripList import; now renders <ArchivedTripModal/>
// directly.
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTrips } from "@/src/hooks/useTrip"; import { useTrips } from "@/src/hooks/useTrip";
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal"; import { TripFormModal, TripTable } from "@/src/Components/Trip";
import { ArchivedTripList } from "@/src/Components/Trip/archive/ArchivedTripList"; import { ArchivedTripModal } from "@/src/Components/Trip/archive/ArchivedTripModal";
import { Alert, Spinner, ArchiveButton, ConfirmDialog , Toast } from "@/src/Components/UI"; import {
Alert,
ArchiveButton,
ConfirmDialog,
Toast,
} from "@/src/Components/UI";
import type { import type {
Trip, Trip,
TripStatus,
CreateTripPayload, CreateTripPayload,
UpdateTripPayload, UpdateTripPayload,
} from "@/src/types/trip"; } from "@/src/types/trip";
import { TRIP_STATUS_MAP } from "@/src/types/trip";
import Header from "@/src/Components/UI/Header"; import Header from "@/src/Components/UI/Header";
// ── Status badge — same visual pattern as Driver's status badge ─────────────
function StatusBadge({ status }: { status: TripStatus }) {
const s = TRIP_STATUS_MAP[status];
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>
);
}
// ── Archived trips modal — unchanged wrapper, kept as-is ────────────────────
function ArchivedTripsModal({ onClose }: { onClose: () => void }) {
const router = useRouter();
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="archived-trips-title"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{
position: "fixed",
inset: 0,
zIndex: 70,
background: "rgba(15,23,42,0.6)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
padding: "2rem 1rem",
overflowY: "auto",
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: 920,
background: "var(--color-surface-sunken)",
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
overflow: "hidden",
}}
>
<div
dir="rtl"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}
>
<h2
id="archived-trips-title"
style={{
fontSize: 17,
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
أرشيف الرحلات
</h2>
<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>
<div style={{ padding: "1.5rem" }}>
{/* Was pointing at the normal trip page — send it to the archived route instead */}
<ArchivedTripList
onView={(trip) =>
router.push(`/dashboard/trips/archived/${trip.id}`)
}
/>
</div>
</div>
</div>
);
}
// ── Page ────────────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────────────
export default function TripsPage() { export default function TripsPage() {
const router = useRouter(); const router = useRouter();
// All list state + mutations now come from the hook, same as useDrivers // All list state + mutations come from the hook, same as useDrivers is
// is used on the Driver page. No direct tripService calls in this file. // used on the Driver page. No direct tripService calls in this file.
const { const {
trips, trips,
total, total,
@@ -169,8 +45,6 @@ export default function TripsPage() {
setPage, setPage,
search, search,
handleSearch, handleSearch,
status,
handleStatusFilter,
createTrip, createTrip,
updateTrip, updateTrip,
deleteTrip, deleteTrip,
@@ -202,6 +76,19 @@ export default function TripsPage() {
setDeleteTarget(trip); setDeleteTarget(trip);
}, []); }, []);
// TripTable exposes a dedicated "view" action (icon button) instead of a
// row click — wired to the trip detail page, same destination the old
// row-click handler used to navigate to.
const handleView = useCallback(
(trip: Trip) => router.push(`/dashboard/trips/${trip.id}`),
[router],
);
const handleAdd = useCallback(() => {
setEditTrip(null);
setShowForm(true);
}, []);
// ── Create / update — delegates to the hook (hook fires the toast) ─────── // ── Create / update — delegates to the hook (hook fires the toast) ───────
const handleSubmit = async ( const handleSubmit = async (
payload: CreateTripPayload | UpdateTripPayload, payload: CreateTripPayload | UpdateTripPayload,
@@ -233,302 +120,45 @@ export default function TripsPage() {
}} }}
> >
{/* ── Header ── */} {/* ── Header ── */}
<Header mainTitle="إدارة الرحلات" <Header
title="الرحلات" mainTitle="إدارة الرحلات"
name="رحلة" title="الرحلات"
state={{ total }} name="رحلة"
search={search} state={{ total }}
setSearch={handleSearch} search={search}
module="" setSearch={handleSearch}
setModule={() => {}} module=""
setPage={setPage} setModule={() => {}}
isAudit={false} setPage={setPage}
onAdd={() => { setEditTrip(null); setShowForm(true); }}/> isAudit={false}
onAdd={handleAdd}
/>
{/* ── Error handling: Alert banner for list/API errors ── {/* ── Error handling: Alert banner for list/API errors ──
Same pattern as the Driver page: a persistent banner tied to the Same pattern as the Driver page: a persistent banner tied to the
hook's `error` state, dismissible via `clearError`. */} hook's `error` state, dismissible via `clearError`. */}
{error && <Alert type="error" message={error} onClose={clearError} />} {error && <Alert type="error" message={error} onClose={clearError} />}
{/* ── Table ── */} {/* ── Table ── */}
<div <TripTable
style={{ trips={trips}
background: "var(--color-surface)", loading={loading}
border: "1px solid var(--color-border)", search={search}
borderRadius: "var(--radius-xl)", page={page}
overflow: "hidden", pages={pages}
boxShadow: "var(--shadow-card)", onEdit={handleEdit}
}} onDelete={handleDelete}
> onView={handleView}
<div onAddFirst={handleAdd}
dir="rtl" onPageChange={setPage}
style={{ />
display: "grid",
gridTemplateColumns: "1.5fr 2fr 1.3fr 1.3fr 1fr 1.1fr 1.1fr 0.8fr",
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)",
}}
>
<span>رقم الرحلة</span>
<span>العنوان</span>
<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>
) : trips.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 }}>
{trips.map((trip, i) => (
<li
key={trip.id}
onClick={() => router.push(`/dashboard/trips/${trip.id}`)}
style={{
display: "grid",
gridTemplateColumns:
"1.5fr 2fr 1.3fr 1.3fr 1fr 1.1fr 1.1fr 0.8fr",
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")
}
>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
color: "var(--color-brand-600)",
fontWeight: 700,
}}
>
{trip.tripNumber}
</span>
<span
style={{
fontWeight: 600,
color: "var(--color-text-primary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{trip.title}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{trip.driver?.name ?? "—"}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{trip.car
? `${trip.car.manufacturer} ${trip.car.model}`
: "—"}
</span>
<StatusBadge status={trip.status} />
<span
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
>
{trip.startTime
? new Date(trip.startTime).toLocaleString("ar-SA", {
dateStyle: "short",
timeStyle: "short",
})
: "—"}
</span>
<span
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
>
{trip.endTime
? new Date(trip.endTime).toLocaleString("ar-SA", {
dateStyle: "short",
timeStyle: "short",
})
: "—"}
</span>
{/* Inline row actions — stopPropagation so the click does not
also trigger navigation to the detail page. */}
<div
style={{ display: "flex", gap: "0.4rem" }}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
aria-label="تعديل الرحلة"
title="تعديل"
onClick={() => handleEdit(trip)}
style={{
width: 30,
height: 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-md)",
cursor: "pointer",
flexShrink: 0,
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={() => handleDelete(trip)}
style={{
width: 30,
height: 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-md)",
cursor: "pointer",
flexShrink: 0,
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" />
</svg>
</button>
</div>
</li>
))}
</ul>
)}
{/* ── Pagination — same pattern as Driver page ── */}
{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>
{" — "}
{total} رحلة
</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>
{/* ── 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) => {
@@ -549,9 +179,11 @@ export default function TripsPage() {
description={`هل أنت متأكد من حذف رحلة ${deleteTarget?.title ?? ""} (${deleteTarget?.tripNumber ?? ""})؟ لا يمكن التراجع عن هذا الإجراء.`} description={`هل أنت متأكد من حذف رحلة ${deleteTarget?.title ?? ""} (${deleteTarget?.tripNumber ?? ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
/> />
{/* ── Archive browser modal ── */} {/* ── Archive browser modal — now the split ArchivedTripModal directly,
no local wrapper needed since it already ships its own search bar,
table, and detail-view wiring. ── */}
{archiveOpen && ( {archiveOpen && (
<ArchivedTripsModal onClose={() => setArchiveOpen(false)} /> <ArchivedTripModal onClose={() => setArchiveOpen(false)} />
)} )}
{/* ── Floating archive button — same component Driver page uses ── */} {/* ── Floating archive button — same component Driver page uses ── */}

View File

@@ -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}

View File

@@ -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={{

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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
View 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*",
],
};

View File

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Spinner } from "../UI"; import { Alert, Badge, Button, Modal, Spinner } from "../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { branchService } from "@/src/services/branch.service"; import { branchService } from "@/src/services/branch.service";
import type { BranchDetail } from "@/src/types/branch"; import type { BranchDetail } from "@/src/types/branch";
@@ -11,7 +11,7 @@ interface BranchDetailModalProps {
onClose: () => void; onClose: () => void;
} }
// ── small helper components ─────────────────────────────────────────────────── // ── small helper components (no template exists for a plain label/value row) ──
function DetailRow({ label, value }: { label: string; value?: string | null }) { function DetailRow({ label, value }: { label: string; value?: string | null }) {
return ( return (
<div style={{ <div style={{
@@ -29,23 +29,7 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
); );
} }
function StatusBadge({ active }: { active: boolean }) { // Branch icon badge has no equivalent in the shared UI kit — kept custom.
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
function BranchIcon() { function BranchIcon() {
return ( return (
<div style={{ <div style={{
@@ -55,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>
); );
} }
@@ -69,29 +50,22 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// fetch branch details on mount // fetch branch details on mount
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
const data = await branchService.getById(branchId, token); const data = await branchService.getById(branchId, token);
if (!cancelled) setBranch(data); if (!cancelled) setBranch(data);
} catch { } catch {
if (!cancelled) setError("تعذّر تحميل بيانات الفرع. يرجى المحاولة لاحقاً."); if (!cancelled) setError("تعذّر تحميل بيانات الفرع. يرجى المحاولة لاحقاً.");
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [branchId]); }, [branchId]);
// ── helpers ─────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────
const fmt = (iso?: string | null) => const fmt = (iso?: string | null) =>
@@ -102,148 +76,71 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
// ── render ──────────────────────────────────────────────────────────────── // ── render ────────────────────────────────────────────────────────────────
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="branch-detail-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={branch?.name ?? "عرض الفرع"}
style={{ subtitle="بيانات الفرع"
position: "fixed", inset: 0, zIndex: 55, onClose={onClose}
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", size="md"
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", footer={
}} <Button type="button" variant="secondary" onClick={onClose}>
إغلاق
</Button>
}
> >
<div {/* loading */}
onClick={e => e.stopPropagation()} {loading && (
style={{ <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
width: "100%", maxWidth: 480, <Spinner size="sm" className="text-blue-600" />
background: "var(--color-surface)", <span style={{ fontSize: 13 }}>جارٍ التحميل</span>
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
}}
>
{/* ── 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 }}>
بيانات الفرع
</p>
<h2 id="branch-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{branch?.name ?? "عرض الفرع"}
</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> </div>
)}
{/* ── body ── */} {/* error */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}> {!loading && error && <Alert type="error" message={error} />}
{/* loading */} {/* content */}
{loading && ( {!loading && branch && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}> <div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error */} {/* icon + name + status row */}
{!loading && error && ( <div style={{
<div style={{ display: "flex", alignItems: "center", gap: "1rem",
padding: "1rem 1.25rem", padding: "0 0 1.25rem",
borderRadius: "var(--radius-lg)", borderBottom: "1px solid var(--color-border)",
background: "#FEF2F2", border: "1px solid #FECACA", marginBottom: "0.25rem",
fontSize: 13, color: "#991B1B", fontWeight: 500, }}>
textAlign: "center", <BranchIcon />
}}> <div style={{ flex: 1, minWidth: 0 }}>
{error} <p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{branch.name}</p>
</div> {branch.city && (
)} <p style={{ marginTop: 3, fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
{branch.city}
{/* content */} </p>
{!loading && branch && ( )}
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl"> <div style={{ marginTop: 8 }}>
<Badge label={branch.isActive ? "نشط" : "معطل"} color={branch.isActive ? "green" : "red"} />
{/* icon + name + status row */}
<div style={{
display: "flex", alignItems: "center", gap: "1rem",
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<BranchIcon />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{branch.name}</p>
{branch.city && (
<p style={{ marginTop: 3, fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
{branch.city}
</p>
)}
<div style={{ marginTop: 8 }}>
<StatusBadge active={branch.isActive} />
</div>
</div>
</div> </div>
{/* detail rows */}
<DetailRow label="رقم الهاتف" value={branch.phone} />
<DetailRow label="البريد الإلكتروني" value={branch.email} />
<DetailRow label="العنوان" value={fullAddress(branch)} />
<DetailRow label="رقم المبنى" value={branch.buildingNo} />
<DetailRow label="رقم الوحدة" value={branch.unitNo} />
<DetailRow label="الرمز البريدي" value={branch.zipCode} />
<DetailRow
label="الموقع الجغرافي"
value={branch.latitude != null && branch.longitude != null
? `${branch.latitude}, ${branch.longitude}`
: null}
/>
<DetailRow label="تاريخ الإنشاء" value={fmt(branch.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(branch.updatedAt)} />
</div> </div>
)} </div>
</div>
{/* ── footer ── */} {/* detail rows */}
<div style={{ <DetailRow label="رقم الهاتف" value={branch.phone} />
padding: "1rem 1.5rem", <DetailRow label="البريد الإلكتروني" value={branch.email} />
borderTop: "1px solid var(--color-border)", <DetailRow label="العنوان" value={fullAddress(branch)} />
background: "var(--color-surface-muted)", <DetailRow label="رقم المبنى" value={branch.buildingNo} />
display: "flex", justifyContent: "flex-end", <DetailRow label="رقم الوحدة" value={branch.unitNo} />
}}> <DetailRow label="الرمز البريدي" value={branch.zipCode} />
<button <DetailRow
type="button" onClick={onClose} label="الموقع الجغرافي"
style={{ value={branch.latitude != null && branch.longitude != null
height: 40, padding: "0 1.5rem", ? `${branch.latitude}, ${branch.longitude}`
borderRadius: "var(--radius-md)", : null}
border: "1px solid var(--color-border)", />
background: "var(--color-surface)", <DetailRow label="تاريخ الإنشاء" value={fmt(branch.createdAt)} />
fontSize: 13, fontWeight: 600, <DetailRow label="آخر تحديث" value={fmt(branch.updatedAt)} />
color: "var(--color-text-secondary)",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إغلاق
</button>
</div> </div>
</div> )}
</div> </Modal>
); );
} }

View File

@@ -1,261 +1,361 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import * as yup from "yup"; import { useForm, Controller } from "react-hook-form";
import { Alert, Spinner } from "../UI"; import { yupResolver } from "@hookform/resolvers/yup";
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 type { Branch, BranchFormData, FormErrors } from "@/src/types/branch"; import {
getRegions,
getCitiesByRegion,
getDistrictsByCity,
resolveLocation,
findLocationByNames,
} from "@/src/lib/locationHierarchy";
import type { Branch, BranchFormData } from "@/src/types/branch";
// ── fixed styles ───────────────────────────────────────── const FORM_ID = "branch-form";
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,
};
// ── yup validation ──────────────────────────────────────────────────────────── // Step 1: Extend the plain form-values shape with the three hierarchy ids.
async function validate(data: BranchFormData, isNew: boolean): Promise<FormErrors> { // BranchFormData itself stays untouched (see src/types/branch.ts) — city/
const schema = isNew ? createBranchSchema : updateBranchSchema; // state/district there are still plain strings, since that's the API
try { // contract. This local type is what react-hook-form actually manages.
await schema.validate(data, { abortEarly: false }); interface BranchFormValues extends BranchFormData {
return {}; regionId: string;
} catch (err) { cityId: string;
if (err instanceof yup.ValidationError) { districtId: string;
return err.inner.reduce<FormErrors>((acc, e) => {
const field = e.path as keyof FormErrors;
if (field && !acc[field]) acc[field] = e.message;
return acc;
}, {});
}
return {};
}
} }
// ── 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;
const [form, setForm] = useState<BranchFormData>({ // Step 2: On edit, try to resolve the branch's stored city/state/district
name: editBranch?.name ?? "", // NAMES back into ids against the current dataset. If nothing matches
email: editBranch?.email ?? "", // (the value is a legacy/unknown location no longer in the hierarchy),
phone: editBranch?.phone ?? "", // resolved stays null and the form falls back to empty selects — the
country: editBranch?.country ?? "SA", // person must actively re-pick a valid Region -> City -> District chain
city: editBranch?.city ?? "", // before they can save, rather than silently keeping a stale value.
state: editBranch?.state ?? "", const resolvedEdit = useMemo(
district: editBranch?.district ?? "", () => (editBranch ? findLocationByNames(editBranch.state, editBranch.city, editBranch.district) : null),
street: editBranch?.street ?? "", [editBranch],
buildingNo: editBranch?.buildingNo ?? "", );
unitNo: editBranch?.unitNo ?? "", const isLegacyLocation = !isNew && !resolvedEdit && !!(editBranch?.city || editBranch?.district);
zipCode: editBranch?.zipCode ?? "",
latitude: editBranch?.latitude != null ? String(editBranch.latitude) : "", const {
longitude: editBranch?.longitude != null ? String(editBranch.longitude) : "", register,
handleSubmit,
setError,
setValue,
watch,
control,
formState: { errors, isSubmitting },
} = useForm<BranchFormValues>({
resolver: yupResolver<BranchFormValues>((isNew ? createBranchSchema : updateBranchSchema) as any),
defaultValues: {
name: editBranch?.name ?? "",
email: editBranch?.email ?? "",
phone: editBranch?.phone ?? "",
country: editBranch?.country ?? "SA",
regionId: resolvedEdit?.regionId ?? "",
cityId: resolvedEdit?.cityId ?? "",
districtId: resolvedEdit?.districtId ?? "",
city: editBranch?.city ?? "",
state: editBranch?.state ?? "",
district: editBranch?.district ?? "",
street: editBranch?.street ?? "",
buildingNo: editBranch?.buildingNo ?? "",
unitNo: editBranch?.unitNo ?? "",
zipCode: editBranch?.zipCode ?? "",
latitude: resolvedEdit?.latitude != null ? String(resolvedEdit.latitude) : editBranch?.latitude != null ? String(editBranch.latitude) : "",
longitude: resolvedEdit?.longitude != null ? String(resolvedEdit.longitude) : editBranch?.longitude != null ? String(editBranch.longitude) : "",
},
}); });
const [errors, setErrors] = useState<FormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const firstInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { firstInputRef.current?.focus(); }, []); // Step 3: Watch the two parent selects so the child dropdowns can filter
// 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(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; if (!watchedCityId) return;
window.addEventListener("keydown", handler); const stillValid = cities.some((c) => c.id === watchedCityId);
return () => window.removeEventListener("keydown", handler); if (!stillValid) {
}, [onClose]); setValue("cityId", "");
setValue("districtId", "");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchedRegionId]);
const set = (field: keyof BranchFormData) => useEffect(() => {
(e: React.ChangeEvent<HTMLInputElement>) => { if (!watchedDistrictId) return;
setForm(p => ({ ...p, [field]: e.target.value })); const stillValid = districts.some((d) => d.id === watchedDistrictId);
// clear field error on change if (!stillValid) setValue("districtId", "");
if (errors[field]) setErrors(p => ({ ...p, [field]: undefined })); // 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 handleSubmit = async (e: React.FormEvent) => { const ok = await onSubmit(payload, isNew);
e.preventDefault(); if (ok) {
const errs = await validate(form, isNew); onClose();
if (Object.keys(errs).length) { setErrors(errs); return; } } else {
setSaving(true); setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
setApiError(""); }
const ok = await onSubmit(form, isNew);
setSaving(false);
if (ok) onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
}; };
// dynamic styles for inputs with errors
const inputStyle = (field: keyof FormErrors): React.CSSProperties => ({
...S.input,
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="branch-modal-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={isNew ? "فرع جديد" : editBranch?.name ?? ""}
style={{ subtitle={isNew ? "إضافة فرع" : "تعديل فرع"}
position: "fixed", inset: 0, zIndex: 50, onClose={onClose}
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", size="md"
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", footer={
}} <>
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
إلغاء
</Button>
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
{isNew ? "إضافة الفرع" : "حفظ التغييرات"}
</Button>
</>
}
> >
<div <form
onClick={e => e.stopPropagation()} id={FORM_ID}
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", maxWidth: 560, noValidate
background: "var(--color-surface)", style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
maxHeight: "90vh",
}}
> >
{/* header */} {errors.name?.type === "manual" && (
<div style={{ <Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
display: "flex", alignItems: "center", justifyContent: "space-between", )}
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)", {/* Step 7: Warn once if the stored location no longer matches any
background: "var(--color-surface-muted)", valid Region -> City -> District chain, and require a fresh
}}> selection instead of silently keeping the legacy value. */}
<div> {isLegacyLocation && (
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> <Alert
{isNew ? "إضافة فرع" : "تعديل فرع"} type="warning"
</p> message="الموقع المحفوظ لهذا الفرع لم يعد مطابقًا لأي منطقة/مدينة/حي معروف. الرجاء اختيار الموقع الصحيح من القوائم أدناه."
<h2 id="branch-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}> onClose={() => {}}
{isNew ? "فرع جديد" : editBranch?.name} />
</h2> )}
</div>
<button type="button" onClick={onClose} aria-label="إغلاق" {/* name */}
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" }}> <Input
× label="اسم الفرع *"
</button> {...register("name")}
error={errors.name && errors.name.type !== "manual" ? errors.name.message : undefined}
placeholder="فرع الرياض"
autoComplete="organization"
autoFocus
dir="rtl"
/>
{/* email + phone */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input
label="البريد الإلكتروني"
type="email"
{...register("email")}
error={errors.email?.message}
placeholder="branch@co.sa"
autoComplete="email"
dir="ltr"
/>
<Input
label="رقم الهاتف"
type="tel"
{...register("phone")}
error={errors.phone?.message}
placeholder="+966 5x xxx xxxx"
autoComplete="tel"
dir="ltr"
/>
</div> </div>
{/* body */} {/* Step 8: Cascading Region -> City -> District selects. Each
<form onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", overflowY: "auto" }}> child <Select> is disabled until its parent has a value, and
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />} 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" }}>
<Controller
name="regionId"
control={control}
render={({ field }) => (
<Select
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>
)}
/>
{/* name */} <Controller
<label style={S.label}> name="cityId"
اسم الفرع * control={control}
<input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="فرع الرياض" autoComplete="organization" dir="rtl" /> render={({ field }) => (
{errors.name && <span style={S.errorText}>{errors.name}</span>} <Select
</label> 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>
{/* email + phone */} <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <Controller
<label style={S.label}> name="districtId"
البريد الإلكتروني control={control}
<input style={inputStyle("email")} type="email" value={form.email} onChange={set("email")} placeholder="branch@co.sa" autoComplete="email" dir="ltr" /> render={({ field }) => (
{errors.email && <span style={S.errorText}>{errors.email}</span>} <Select
</label> label="الحي *"
<label style={S.label}> value={field.value}
رقم الهاتف onChange={field.onChange}
<input style={inputStyle("phone")} type="tel" value={form.phone} onChange={set("phone")} placeholder="+966 5x xxx xxxx" autoComplete="tel" dir="ltr" /> error={(errors as any).districtId?.message}
{errors.phone && <span style={S.errorText}>{errors.phone}</span>} disabled={!watchedCityId}
</label> dir="rtl"
</div> >
<option value="">{watchedCityId ? "اختر الحي" : "اختر المدينة أولاً"}</option>
{districts.map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
)}
/>
{/* city + street */} <Input
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> label="الشارع *"
<label style={S.label}> {...register("street")}
المدينة * error={errors.street?.message}
<input style={inputStyle("city")} value={form.city} onChange={set("city")} placeholder="الرياض" dir="rtl" /> placeholder="شارع الملك فهد"
{errors.city && <span style={S.errorText}>{errors.city}</span>} dir="rtl"
</label> />
<label style={S.label}> </div>
الشارع *
<input style={inputStyle("street")} value={form.street} onChange={set("street")} placeholder="شارع الملك فهد" dir="rtl" />
{errors.street && <span style={S.errorText}>{errors.street}</span>}
</label>
</div>
{/* state + district */} {/* buildingNo + unitNo + zipCode */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}> <Input
المنطقة label="رقم المبنى"
<input style={inputStyle("state")} value={form.state} onChange={set("state")} placeholder="منطقة الرياض" dir="rtl" /> {...register("buildingNo")}
{errors.state && <span style={S.errorText}>{errors.state}</span>} error={errors.buildingNo?.message}
</label> placeholder="1234"
<label style={S.label}> dir="ltr"
الحي />
<input style={inputStyle("district")} value={form.district} onChange={set("district")} placeholder="حي العليا" dir="rtl" /> <Input
{errors.district && <span style={S.errorText}>{errors.district}</span>} label="رقم الوحدة"
</label> {...register("unitNo")}
</div> error={errors.unitNo?.message}
placeholder="5"
dir="ltr"
/>
<Input
label="الرمز البريدي"
{...register("zipCode")}
error={errors.zipCode?.message}
placeholder="12345"
dir="ltr"
/>
</div>
{/* buildingNo + unitNo + zipCode */} {/* country */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}> <Input
<label style={S.label}> label="الدولة"
رقم المبنى {...register("country")}
<input style={inputStyle("buildingNo")} value={form.buildingNo} onChange={set("buildingNo")} placeholder="1234" dir="ltr" /> error={errors.country?.message}
{errors.buildingNo && <span style={S.errorText}>{errors.buildingNo}</span>} placeholder="SA"
</label> dir="ltr"
<label style={S.label}> disabled
رقم الوحدة />
<input style={inputStyle("unitNo")} value={form.unitNo} onChange={set("unitNo")} placeholder="5" dir="ltr" />
{errors.unitNo && <span style={S.errorText}>{errors.unitNo}</span>}
</label>
<label style={S.label}>
الرمز البريدي
<input style={inputStyle("zipCode")} value={form.zipCode} onChange={set("zipCode")} placeholder="12345" dir="ltr" />
{errors.zipCode && <span style={S.errorText}>{errors.zipCode}</span>}
</label>
</div>
{/* country */} {/* Step 9: latitude/longitude are now READ-ONLY — they are derived
<label style={S.label}> automatically from the selected district (Step 5 above), so
الدولة there is no manual entry and therefore no coordinate-vs-district
<input style={inputStyle("country")} value={form.country} onChange={set("country")} placeholder="SA" dir="ltr" /> mismatch possible from this form. */}
{errors.country && <span style={S.errorText}>{errors.country}</span>} <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
</label> <Input
label="خط العرض (تلقائي)"
{/* latitude + longitude */} {...register("latitude")}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> error={errors.latitude?.message}
<label style={S.label}> placeholder="—"
خط العرض (اختياري) dir="ltr"
<input style={inputStyle("latitude")} value={form.latitude} onChange={set("latitude")} placeholder="24.7136" dir="ltr" inputMode="decimal" /> inputMode="decimal"
{errors.latitude && <span style={S.errorText}>{errors.latitude}</span>} readOnly
</label> disabled
<label style={S.label}> />
خط الطول (اختياري) <Input
<input style={inputStyle("longitude")} value={form.longitude} onChange={set("longitude")} placeholder="46.6753" dir="ltr" inputMode="decimal" /> label="خط الطول (تلقائي)"
{errors.longitude && <span style={S.errorText}>{errors.longitude}</span>} {...register("longitude")}
</label> error={errors.longitude?.message}
</div> placeholder="—"
dir="ltr"
{/* actions */} inputMode="decimal"
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}> readOnly
<button type="button" onClick={onClose} disabled={saving} disabled
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)" }}> />
إلغاء </div>
</button> </form>
<button type="submit" disabled={saving} </Modal>
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>
); );
} }

View File

@@ -1,155 +1,96 @@
"use client"; "use client";
import { 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";
// ── status badge ─────────────────────────────────────────────────────────────
function StatusBadge({ active }: { active: boolean }) {
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={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
// ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── 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
<div style={{ textAlign: "center", padding: "4rem 1rem" }}> itemLabel={b.name}
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}> onView={() => onView(b)}
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."} onEdit={() => onEdit(b)}
</p> onDelete={() => onDelete(b)}
{!search && ( />
<button type="button" onClick={onAddFirst}
style={{ marginTop: 12, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
أضف أول فرع
</button>
)}
</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 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>
<StatusBadge active={b.isActive} />
<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" 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>
); );
} }

View 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>
);
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect } from "react"; import { Badge, Button, Modal } from "../../UI";
import type { ArchivedBranch } from "@/src/types/branch"; import type { ArchivedBranch } from "@/src/types/branch";
interface ArchivedBranchDetailModalProps { interface ArchivedBranchDetailModalProps {
@@ -26,23 +26,6 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
); );
} }
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
function BranchIcon() { function BranchIcon() {
return ( return (
<div style={{ <div style={{
@@ -52,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>
); );
} }
@@ -65,13 +45,6 @@ function BranchIcon() {
// modal does NOT fetch — it just renders the ArchivedBranch object that was // modal does NOT fetch — it just renders the ArchivedBranch object that was
// already loaded from GET /branches/archived (passed down from the table). // already loaded from GET /branches/archived (passed down from the table).
export function ArchivedBranchDetailModal({ branch, onClose }: ArchivedBranchDetailModalProps) { export function ArchivedBranchDetailModal({ branch, onClose }: ArchivedBranchDetailModalProps) {
// close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// ── helpers ─────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────
const fmt = (iso?: string | null) => const fmt = (iso?: string | null) =>
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null; iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null;
@@ -81,126 +54,61 @@ export function ArchivedBranchDetailModal({ branch, onClose }: ArchivedBranchDet
// ── render ──────────────────────────────────────────────────────────────── // ── render ────────────────────────────────────────────────────────────────
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="archived-branch-detail-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} onClose={onClose}
style={{ size="md"
position: "fixed", inset: 0, zIndex: 55, subtitle="فرع مؤرشف"
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", title={branch.name}
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", zIndex={60}
}} footer={
<Button variant="secondary" onClick={onClose}>
إغلاق
</Button>
}
> >
<div <div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
onClick={e => e.stopPropagation()}
style={{ {/* icon + name + status row */}
width: "100%", maxWidth: 480,
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",
display: "flex", flexDirection: "column",
}}
>
{/* ── header ── */}
<div style={{ <div style={{
display: "flex", alignItems: "center", justifyContent: "space-between", display: "flex", alignItems: "center", gap: "1rem",
padding: "1.25rem 1.5rem", padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)", borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)", marginBottom: "0.25rem",
}}> }}>
<div> <BranchIcon />
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
فرع مؤرشف <p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{branch.name}</p>
<p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
#{branch.id}
</p> </p>
<h2 id="archived-branch-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}> <div style={{ marginTop: 8 }}>
{branch.name} <Badge label={branch.isActive ? "نشط" : "معطل"} color={branch.isActive ? "green" : "red"} />
</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 ── */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
{/* icon + name + status row */}
<div style={{
display: "flex", alignItems: "center", gap: "1rem",
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<BranchIcon />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{branch.name}</p>
<p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
#{branch.id}
</p>
<div style={{ marginTop: 8 }}>
<StatusBadge active={branch.isActive} />
</div>
</div>
</div> </div>
{/* detail rows — every field requested */}
<DetailRow label="رقم الهاتف" value={branch.phone} />
<DetailRow label="البريد الإلكتروني" value={branch.email} />
<DetailRow label="الدولة" value={branch.country} />
<DetailRow label="المدينة" value={branch.city} />
<DetailRow label="المنطقة" value={branch.state} />
<DetailRow label="الحي" value={branch.district} />
<DetailRow label="الشارع" value={branch.street} />
<DetailRow label="العنوان الكامل" value={fullAddress(branch)} />
<DetailRow label="رقم المبنى" value={branch.buildingNo} />
<DetailRow label="رقم الوحدة" value={branch.unitNo} />
<DetailRow label="الرمز البريدي" value={branch.zipCode} />
<DetailRow
label="الموقع الجغرافي"
value={branch.latitude != null && branch.longitude != null
? `${branch.latitude}, ${branch.longitude}`
: null}
/>
<DetailRow label="تاريخ الإنشاء" value={fmt(branch.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(branch.updatedAt)} />
</div> </div>
</div> </div>
{/* ── footer ── */} {/* detail rows — every field requested */}
<div style={{ <DetailRow label="رقم الهاتف" value={branch.phone} />
padding: "1rem 1.5rem", <DetailRow label="البريد الإلكتروني" value={branch.email} />
borderTop: "1px solid var(--color-border)", <DetailRow label="الدولة" value={branch.country} />
background: "var(--color-surface-muted)", <DetailRow label="المدينة" value={branch.city} />
display: "flex", justifyContent: "flex-end", <DetailRow label="المنطقة" value={branch.state} />
}}> <DetailRow label="الحي" value={branch.district} />
<button <DetailRow label="الشارع" value={branch.street} />
type="button" onClick={onClose} <DetailRow label="العنوان الكامل" value={fullAddress(branch)} />
style={{ <DetailRow label="رقم المبنى" value={branch.buildingNo} />
height: 40, padding: "0 1.5rem", <DetailRow label="رقم الوحدة" value={branch.unitNo} />
borderRadius: "var(--radius-md)", <DetailRow label="الرمز البريدي" value={branch.zipCode} />
border: "1px solid var(--color-border)", <DetailRow
background: "var(--color-surface)", label="الموقع الجغرافي"
fontSize: 13, fontWeight: 600, value={branch.latitude != null && branch.longitude != null
color: "var(--color-text-secondary)", ? `${branch.latitude}, ${branch.longitude}`
cursor: "pointer", fontFamily: "var(--font-sans)", : null}
}} />
> <DetailRow label="تاريخ الإنشاء" value={fmt(branch.createdAt)} />
إغلاق <DetailRow label="آخر تحديث" value={fmt(branch.updatedAt)} />
</button>
</div>
</div> </div>
</div> </Modal>
); );
} }

View File

@@ -1,134 +1,70 @@
"use client"; "use client";
import { 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";
// ── status badge ─────────────────────────────────────────────────────────────
function StatusBadge({ active }: { active: boolean }) {
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={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
// ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── 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>
<StatusBadge active={b.isActive} />
<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" }}>
{[
{ 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" 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>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "../../UI"; import { Alert, Input, Modal } from "../../UI";
import { ArchivedBranchTable } from "./ArchivedBranchTable"; import { ArchivedBranchTable } from "./ArchivedBranchTable";
import { ArchivedBranchDetailModal } from "./ArchivedBranchDetailModal"; import { ArchivedBranchDetailModal } from "./ArchivedBranchDetailModal";
import { useArchivedBranches } from "@/src/hooks/archive/useArchiveBranch"; import { useArchivedBranches } from "@/src/hooks/archive/useArchiveBranch";
@@ -11,6 +11,10 @@ interface ArchivedBranchesModalProps {
onClose: () => void; onClose: () => void;
} }
const SearchIcon = (
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
);
export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) { export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {
// hold the full object, not just the id — no GET /branches/archived/{id} route exists // hold the full object, not just the id — no GET /branches/archived/{id} route exists
const [viewBranch, setViewBranch] = useState<ArchivedBranch | null>(null); const [viewBranch, setViewBranch] = useState<ArchivedBranch | null>(null);
@@ -22,79 +26,24 @@ export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {
} = useArchivedBranches(); } = useArchivedBranches();
return ( return (
<div <>
role="dialog" aria-modal="true" aria-labelledby="branch-archive-modal-title" <Modal
onClick={e => { if (e.target === e.currentTarget) onClose(); }} open
style={{ onClose={onClose}
position: "fixed", inset: 0, zIndex: 70, size="lg"
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", subtitle="الأرشيف"
display: "flex", alignItems: "flex-start", justifyContent: "center", title={`الفروع المؤرشفة (${total})`}
padding: "2rem 1rem", overflowY: "auto",
}}
>
{viewBranch && (
<ArchivedBranchDetailModal branch={viewBranch} onClose={() => setViewBranch(null)} />
)}
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 920,
background: "var(--color-surface-sunken)",
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
overflow: "hidden",
}}
> >
{/* header */} <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="branch-archive-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
الفروع المؤرشفة
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</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 */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */} {/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}> <div dir="rtl" style={{ maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }} <Input
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> label="بحث"
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالاسم أو المدينة..." placeholder="بحث بالاسم أو المدينة..."
value={search} value={search}
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
dir="rtl" dir="rtl"
style={{ icon={SearchIcon}
width: "100%", height: 40,
paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/> />
</div> </div>
@@ -111,7 +60,14 @@ export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {
onPageChange={setPage} onPageChange={setPage}
/> />
</div> </div>
</div> </Modal>
</div>
{/* Rendered after the list modal so it stacks visually on top of it
(both use the shared Modal's fixed full-screen overlay at the same
z-index — later in the DOM wins the paint order). */}
{viewBranch && (
<ArchivedBranchDetailModal branch={viewBranch} onClose={() => setViewBranch(null)} />
)}
</>
); );
} }

View 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>
);
}

View File

@@ -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>

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form";
import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Spinner } from "../UI"; import { Alert, Button, Input, Modal } from "../UI";
import { import {
createMaintenanceSchema, createMaintenanceSchema,
updateMaintenanceSchema, updateMaintenanceSchema,
@@ -10,48 +10,16 @@ import {
import type { import type {
CarMaintenance, CarMaintenance,
CreateMaintenancePayload, CreateMaintenancePayload,
MaintenanceFormErrors,
UpdateMaintenancePayload, UpdateMaintenancePayload,
} from "@/src/types/carMaintanance"; } from "@/src/types/carMaintanance";
// ── Shared input style ──────────────────────────────────────────────────────── const FORM_ID = "maintenance-form";
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)",
};
const errorTextStyle: React.CSSProperties = {
fontSize: 11,
color: "var(--color-danger)",
fontWeight: 500,
};
// ── Number coercion helper ──────────────────────────────────────────────────── // ── Number coercion helper ────────────────────────────────────────────────────
// The backend can return `cost` as a numeric string (common with Decimal // The backend can return `cost` as a numeric string (common with Decimal
// columns getting JSON-serialized as strings), even though our TS type says // columns getting JSON-serialized as strings), even though our TS type says
// `number`. If that untouched value round-trips back out on update without // `number`. Coerce defensively both when hydrating the form AND right before
// ever passing through the `<input type="number">` onChange handler, it // building the submit payload, so this can never slip through as a string.
// stays a string and fails the backend's strict `z.number()` check. Coerce
// defensively both when hydrating the form AND right before building the
// submit payload, so this can never happen regardless of the source.
function toNumberOrUndefined(v: unknown): number | undefined { function toNumberOrUndefined(v: unknown): number | undefined {
if (v === undefined || v === null || v === "") return undefined; if (v === undefined || v === null || v === "") return undefined;
@@ -59,30 +27,6 @@ function toNumberOrUndefined(v: unknown): number | undefined {
return Number.isNaN(n) ? undefined : n; return Number.isNaN(n) ? undefined : n;
} }
// ── yup validation ────────────────────────────────────────────────────────────
// Checks the form values against the right schema and turns any problems
// into a simple field -> message map the inputs below can read from.
async function validate(
form: Partial<CreateMaintenancePayload>,
isNew: boolean,
): Promise<MaintenanceFormErrors> {
const schema = isNew ? createMaintenanceSchema : updateMaintenanceSchema;
try {
await schema.validate(form, { abortEarly: false });
return {};
} catch (err) {
if (err instanceof yup.ValidationError) {
return err.inner.reduce<MaintenanceFormErrors>((acc, e) => {
const field = e.path as keyof MaintenanceFormErrors;
if (field && !acc[field]) acc[field] = e.message;
return acc;
}, {});
}
return {};
}
}
// ── Date helper ─────────────────────────────────────────────────────────────── // ── Date helper ───────────────────────────────────────────────────────────────
// <input type="date"> gives "YYYY-MM-DD"; the backend expects full ISO-8601. // <input type="date"> gives "YYYY-MM-DD"; the backend expects full ISO-8601.
@@ -92,6 +36,19 @@ function toIsoDateTime(val: string): string {
return `${val}T00:00:00.000Z`; return `${val}T00:00:00.000Z`;
} }
// ── Form values shape ────────────────────────────────────────────────────────
// `cost` stays number | undefined (via setValueAs) so an empty input maps to
// `undefined` rather than NaN; startAt/endAt stay as raw "YYYY-MM-DD" strings
// while typed — yup's dateString test accepts that format directly — and are
// only converted to full ISO-8601 right before the payload is built.
interface MaintenanceFormValues {
reason: string;
cost: number | undefined;
startAt: string;
endAt: string;
}
// ── Props ───────────────────────────────────────────────────────────────────── // ── Props ─────────────────────────────────────────────────────────────────────
interface CarMaintenanceFormModalProps { interface CarMaintenanceFormModalProps {
@@ -116,208 +73,121 @@ export function CarMaintenanceFormModal({
}: CarMaintenanceFormModalProps) { }: CarMaintenanceFormModalProps) {
const isNew = editRecord === null; const isNew = editRecord === null;
// ── Form state ───────────────────────────────────────────────────────────── const {
const [reason, setReason] = useState(editRecord?.reason ?? ""); register,
// Coerced defensively: editRecord.cost may arrive as a numeric string from handleSubmit,
// the backend (e.g. a Decimal column serialized to JSON as "150"). setError,
const [cost, setCost] = useState<number | undefined>(toNumberOrUndefined(editRecord?.cost)); formState: { errors, isSubmitting },
const [startAt, setStartAt] = useState(editRecord?.startAt?.slice(0, 10) ?? ""); } = useForm<MaintenanceFormValues>({
const [endAt, setEndAt] = useState(editRecord?.endAt?.slice(0, 10) ?? ""); // Cast the schema itself and pin the generic explicitly — create/update
// schemas differ structurally in which fields are required, so yup can't
const [errors, setErrors] = useState<MaintenanceFormErrors>({}); // unify them into MaintenanceFormValues on its own.
const [saving, setSaving] = useState(false); resolver: yupResolver<MaintenanceFormValues>(
const [apiError, setApiError] = useState(""); (isNew ? createMaintenanceSchema : updateMaintenanceSchema) as any,
const firstRef = useRef<HTMLInputElement>(null); ),
defaultValues: {
useEffect(() => { firstRef.current?.focus(); }, []); reason: editRecord?.reason ?? "",
useEffect(() => { // Coerced defensively: editRecord.cost may arrive as a numeric string
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; // from the backend (e.g. a Decimal column serialized to JSON as "150").
window.addEventListener("keydown", h); cost: toNumberOrUndefined(editRecord?.cost),
return () => window.removeEventListener("keydown", h); startAt: editRecord?.startAt?.slice(0, 10) ?? "",
}, [onClose]); endAt: editRecord?.endAt?.slice(0, 10) ?? "",
},
const parseNum = (v: string): number | undefined =>
v.trim() === "" ? undefined : Number(v);
const inputStyle = (field: keyof MaintenanceFormErrors): React.CSSProperties => ({
...inputBase,
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
}); });
const clearFieldError = (field: keyof MaintenanceFormErrors) => const costField = register("cost", {
setErrors((p) => ({ ...p, [field]: undefined })); setValueAs: (v) => (v === "" || v === null ? undefined : Number(v)),
});
// ── Submit ──────────────────────────────────────────────────────────────── // ── Submit ────────────────────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => { const submitHandler = async (data: MaintenanceFormValues) => {
e.preventDefault();
// Belt-and-braces: re-coerce cost right before it's used, in case it // Belt-and-braces: re-coerce cost right before it's used, in case it
// somehow slipped back into a string (e.g. untouched value hydrated // somehow slipped back into a string.
// from a record whose `cost` came back as a numeric string). const safeCost = toNumberOrUndefined(data.cost);
const safeCost = toNumberOrUndefined(cost);
// Build a snapshot for yup — include everything so optional rules also run. // Dates go out as full ISO-8601 strings, and cost always goes out as a
const formSnapshot: Partial<CreateMaintenancePayload> = { // real number, never a string.
reason, const raw: Record<string, unknown> = { reason: data.reason, cost: safeCost };
cost: safeCost, if (data.startAt) raw.startAt = toIsoDateTime(data.startAt);
...(startAt && { startAt }), if (data.endAt) raw.endAt = toIsoDateTime(data.endAt);
...(endAt && { endAt }),
};
const errs = await validate(formSnapshot, isNew);
if (Object.keys(errs).length) {
setErrors(errs);
return;
}
// Build the final payload — dates go out as full ISO-8601 strings, and
// cost always goes out as a real number, never a string.
const raw: Record<string, unknown> = { reason, cost: safeCost };
if (startAt) raw.startAt = toIsoDateTime(startAt);
if (endAt) raw.endAt = toIsoDateTime(endAt);
const payload = raw as unknown as CreateMaintenancePayload; const payload = raw as unknown as CreateMaintenancePayload;
setSaving(true);
setApiError("");
const ok = await onSubmit(payload, isNew); const ok = await onSubmit(payload, isNew);
setSaving(false); if (ok) {
if (ok) onClose(); onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."); } else {
setError("reason", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
}
}; };
// ── Render ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
return ( return (
<div <Modal
role="dialog" open
aria-modal="true" onClose={onClose}
aria-labelledby="maintenance-modal-title" size="sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} subtitle={isNew ? "إضافة سجل صيانة" : "تعديل سجل صيانة"}
style={{ title={carLabel ?? (isNew ? "سجل صيانة جديد" : "تعديل السجل")}
position: "fixed", inset: 0, zIndex: 50, footer={
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", <>
display: "flex", alignItems: "center", justifyContent: "center", <Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}>
padding: "1rem", overflowY: "auto", إلغاء
}} </Button>
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
{isNew ? "إضافة السجل" : "حفظ التغييرات"}
</Button>
</>
}
> >
<div <form
onClick={(e) => e.stopPropagation()} id={FORM_ID}
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", maxWidth: 520, noValidate
background: "var(--color-surface)", dir="rtl"
borderRadius: "var(--radius-2xl)", className="flex flex-col gap-4"
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
margin: "auto",
}}
> >
{/* Header */} {errors.reason?.type === "manual" && (
<div style={{ <Alert type="error" message={errors.reason.message ?? ""} onClose={() => {}} />
display: "flex", alignItems: "center", justifyContent: "space-between", )}
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)", <Input
background: "var(--color-surface-muted)", label="سبب الصيانة *"
}}> {...register("reason")}
<div> error={errors.reason && errors.reason.type !== "manual" ? errors.reason.message : undefined}
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> placeholder="تغيير زيت المحرك"
{isNew ? "إضافة سجل صيانة" : "تعديل سجل صيانة"} dir="rtl"
</p> autoFocus
<h2 id="maintenance-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}> />
{carLabel ?? (isNew ? "سجل صيانة جديد" : "تعديل السجل")}
</h2> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
</div> <Input
<button type="button" onClick={onClose} aria-label="إغلاق" 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" }}> type="number"
× min={0}
</button> {...costField}
error={errors.cost?.message}
placeholder="0"
dir="ltr"
/>
<Input
label="تاريخ البدء *"
type="date"
{...register("startAt")}
error={errors.startAt?.message}
/>
</div> </div>
{/* Form */} <Input
<form label="تاريخ الانتهاء"
onSubmit={handleSubmit} type="date"
noValidate {...register("endAt")}
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", maxHeight: "75vh", overflowY: "auto" }} error={errors.endAt?.message}
dir="rtl" hint="اتركه فارغاً إذا كانت الصيانة ما زالت جارية."
> />
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />} </form>
</Modal>
<label style={labelStyle}>
سبب الصيانة *
<input
ref={firstRef}
style={inputStyle("reason")}
value={reason}
onChange={(e) => { setReason(e.target.value); clearFieldError("reason"); }}
placeholder="تغيير زيت المحرك"
dir="rtl"
/>
{errors.reason && <span style={errorTextStyle}>{errors.reason}</span>}
</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
التكلفة (ر.س) *
<input
style={inputStyle("cost")}
type="number"
min={0}
value={cost ?? ""}
onChange={(e) => { setCost(parseNum(e.target.value)); clearFieldError("cost"); }}
placeholder="0"
dir="ltr"
/>
{errors.cost && <span style={errorTextStyle}>{errors.cost}</span>}
</label>
<label style={labelStyle}>
تاريخ البدء *
<input
style={inputStyle("startAt")}
type="date"
value={startAt}
onChange={(e) => { setStartAt(e.target.value); clearFieldError("startAt"); }}
/>
{errors.startAt && <span style={errorTextStyle}>{errors.startAt}</span>}
</label>
</div>
<label style={labelStyle}>
تاريخ الانتهاء
<input
style={inputStyle("endAt")}
type="date"
value={endAt}
onChange={(e) => { setEndAt(e.target.value); clearFieldError("endAt"); }}
/>
{errors.endAt && <span style={errorTextStyle}>{errors.endAt}</span>}
<span style={{ fontSize: 11, color: "var(--color-text-hint)", fontWeight: 400 }}>
اتركه فارغاً إذا كانت الصيانة ما زالت جارية.
</span>
</label>
{/* 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>
); );
} }

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useEffect } from "react"; import { Alert, Badge, Button, Modal, Spinner } from "../../UI";
import { Spinner } from "../../UI";
import { import {
MAINTENANCE_STATUS_MAP, MAINTENANCE_STATUS_MAP,
fmtDate, fmtDate,
@@ -33,106 +32,60 @@ function DetailRow({ label, value, mono = false }: { label: string; value: strin
} }
export function ArchivedCarMaintenanceDetailPanel({ record, loading, error, onClose }: ArchivedCarMaintenanceDetailPanelProps) { export function ArchivedCarMaintenanceDetailPanel({ record, loading, error, onClose }: ArchivedCarMaintenanceDetailPanelProps) {
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="archived-maintenance-detail-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={record ? record.reason : "عرض السجل"}
style={{ subtitle="سجل صيانة مؤرشف"
position: "fixed", inset: 0, zIndex: 80, onClose={onClose}
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", zIndex={60}
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", size="md"
}} footer={
<Button type="button" variant="secondary" onClick={onClose}>
إغلاق
</Button>
}
> >
<div <div dir="rtl">
onClick={e => e.stopPropagation()} {loading && (
style={{ <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
width: "100%", maxWidth: 480, <Spinner size="sm" className="text-blue-600" />
background: "var(--color-surface)", <span style={{ fontSize: 13 }}>جارٍ التحميل</span>
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
}}
>
<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: "#EA580C", fontWeight: 600, margin: 0 }}>
سجل صيانة مؤرشف
</p>
<h2 id="archived-maintenance-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{record ? record.reason : "عرض السجل"}
</h2>
</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" }}>
×
</button>
</div>
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }} dir="rtl"> {!loading && error && <Alert type="error" message={error} />}
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{!loading && error && ( {!loading && record && (() => {
<div style={{ padding: "1rem 1.25rem", borderRadius: "var(--radius-lg)", background: "#FEF2F2", border: "1px solid #FECACA", fontSize: 13, color: "#991B1B", fontWeight: 500, textAlign: "center" }}> const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)];
{error} return (
</div> <>
)} <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}>
{/* Dynamic per-status colors come from MAINTENANCE_STATUS_MAP, which
doesn't map cleanly onto the fixed Badge color palette — kept custom. */}
<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>
<Badge label="مؤرشف" color="red" />
</div>
{!loading && record && (() => { {record.car && (
const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)]; <DetailRow
return ( label="المركبة"
<> value={`${record.car.manufacturer} ${record.car.model}${record.car.plateLetters} ${record.car.plateNumber}`}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}> mono
<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> <DetailRow label="التكلفة" value={fmtCost(record.cost)} />
<span style={{ borderRadius: "var(--radius-full)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: "#DC2626" }}> <DetailRow label="تاريخ البدء" value={fmtDate(record.startAt)} />
مؤرشف <DetailRow label="تاريخ الانتهاء" value={fmtDate(record.endAt)} />
</span> <DetailRow label="المدة" value={`${durationDays(record.startAt, record.endAt)} يوم`} />
</div> <DetailRow label="تاريخ الإضافة" value={fmtDate(record.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(record.updatedAt)} />
{record.car && ( </>
<DetailRow );
label="المركبة" })()}
value={`${record.car.manufacturer} ${record.car.model}${record.car.plateLetters} ${record.car.plateNumber}`}
mono
/>
)}
<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)} يوم`} />
<DetailRow label="تاريخ الإضافة" value={fmtDate(record.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(record.updatedAt)} />
</>
);
})()}
</div>
<div style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", display: "flex", justifyContent: "flex-end" }}>
<button type="button" onClick={onClose}
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>
</div> </div>
</div> </Modal>
); );
} }

View File

@@ -1,31 +1,12 @@
"use client"; "use client";
import { Spinner } from "../../UI";
import { IconBtn, ReusableTable } from "../../UI";
import { fmtDateShort, fmtCost } from "@/src/types/carMaintanance"; import { fmtDateShort, fmtCost } from "@/src/types/carMaintanance";
import type { TableColumn } from "@/src/types/models";
import type { CarMaintenance } 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)",
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)",
};
function IconBtn({ onClick, title, color, bg, borderColor, children }: {
onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode;
}) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
interface ArchivedCarMaintenanceTableProps { interface ArchivedCarMaintenanceTableProps {
records: CarMaintenance[]; records: CarMaintenance[];
loading: boolean; loading: boolean;
@@ -41,87 +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)}
<div style={{ textAlign: "center", padding: "4rem 1rem" }}> >
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}> <i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."} </IconBtn>
</p>
</div>
) : (
<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" 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>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "../../UI"; import { Alert, Input, Modal } from "../../UI";
import { ArchivedCarMaintenanceTable } from "./ArchivedCarMaintananceTable"; import { ArchivedCarMaintenanceTable } from "./ArchivedCarMaintananceTable";
import { ArchivedCarMaintenanceDetailPanel } from "./ArchivedCarMaintananceDetailpanel"; import { ArchivedCarMaintenanceDetailPanel } from "./ArchivedCarMaintananceDetailpanel";
import { useArchivedCarMaintenance } from "@/src/hooks/archive/UseArchivedCarsMaintanance"; import { useArchivedCarMaintenance } from "@/src/hooks/archive/UseArchivedCarsMaintanance";
@@ -25,15 +25,12 @@ export function ArchivedCarsMaintenanceModal({ carId, carLabel, onClose }: Archi
} = useArchivedCarMaintenance(carId); } = useArchivedCarMaintenance(carId);
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="archive-maintenance-modal-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={`سجلات الصيانة المؤرشفة${carLabel ? `${carLabel}` : ""} (${total})`}
style={{ subtitle="الأرشيف"
position: "fixed", inset: 0, zIndex: 70, onClose={onClose}
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", size="lg"
display: "flex", alignItems: "flex-start", justifyContent: "center",
padding: "2rem 1rem", overflowY: "auto",
}}
> >
{viewRecord && ( {viewRecord && (
<ArchivedCarMaintenanceDetailPanel <ArchivedCarMaintenanceDetailPanel
@@ -44,79 +41,34 @@ export function ArchivedCarsMaintenanceModal({ carId, carLabel, onClose }: Archi
/> />
)} )}
<div <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
onClick={e => e.stopPropagation()} <div style={{ maxWidth: 320 }}>
style={{ <Input
width: "100%", maxWidth: 980, label="بحث"
background: "var(--color-surface-sunken)", type="text"
borderRadius: "var(--radius-xl)", placeholder="بحث بسبب الصيانة..."
border: "1px solid var(--color-border)", value={search}
boxShadow: "0 24px 64px rgba(0,0,0,.2)", onChange={e => handleSearch(e.target.value)}
overflow: "hidden", dir="rtl"
}} icon={
> <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
<div dir="rtl" style={{ }
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-maintenance-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
سجلات الصيانة المؤرشفة{carLabel ? `${carLabel}` : ""}
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</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>
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
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)}
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, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/>
</div>
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
<ArchivedCarMaintenanceTable
records={records}
loading={loading}
search={search}
page={page}
pages={pages}
showCar={!carId}
onView={record => setViewRecord(record)}
onPageChange={setPage}
/> />
</div> </div>
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
<ArchivedCarMaintenanceTable
records={records}
loading={loading}
search={search}
page={page}
pages={pages}
showCar={!carId}
onView={record => setViewRecord(record)}
onPageChange={setPage}
/>
</div> </div>
</div> </Modal>
); );
} }

View 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>
);
}

View File

@@ -1,9 +1,8 @@
"use client"; "use client";
import { useEffect } 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, Spinner } from "../UI"; import { Alert, Button, Input, Modal, Select } from "../UI";
import { import {
createClientSchema, createClientSchema,
updateClientSchema, updateClientSchema,
@@ -11,39 +10,7 @@ import {
} from "@/src/validations/client.validator"; } from "@/src/validations/client.validator";
import type { Client, ClientFormData } from "@/src/types/client"; import type { Client, ClientFormData } from "@/src/types/client";
// ── Styles ───────────────────────────────────────────────────────────────── const FORM_ID = "client-form";
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,
};
const withError = (hasError: boolean): React.CSSProperties => ({
...S.input,
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
// ── Props ────────────────────────────────────────────────────────────────── // ── Props ──────────────────────────────────────────────────────────────────
interface ClientFormModalProps { interface ClientFormModalProps {
@@ -59,7 +26,6 @@ interface ClientFormModalProps {
export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormModalProps) { export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormModalProps) {
const isNew = editClient === null; const isNew = editClient === null;
// FIX 1: was `Schema` (capital S) — now correctly `schema`
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -76,12 +42,6 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod
}, },
}); });
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const submitHandler = async (data: ClientFormData) => { const submitHandler = async (data: ClientFormData) => {
const ok = await onSubmit(data, isNew); const ok = await onSubmit(data, isNew);
if (ok) { if (ok) {
@@ -92,255 +52,91 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod
}; };
return ( return (
<div <Modal
role="dialog" open
aria-modal="true" onClose={onClose}
aria-labelledby="client-modal-title" size="sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} subtitle={isNew ? "إضافة عميل" : "تعديل عميل"}
style={{ title={isNew ? "عميل جديد" : editClient?.name ?? ""}
position: "fixed", footer={
inset: 0, <>
zIndex: 50, <Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}>
background: "rgba(15,23,42,0.55)", إلغاء
backdropFilter: "blur(4px)", </Button>
display: "flex", <Button type="submit" form={FORM_ID} loading={isSubmitting}>
alignItems: "center", {isNew ? "إضافة العميل" : "حفظ التغييرات"}
justifyContent: "center", </Button>
padding: "1rem", </>
}} }
> >
<div <form
onClick={(e) => e.stopPropagation()} id={FORM_ID}
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", noValidate
maxWidth: 520, dir="rtl"
background: "var(--color-surface)", className="flex flex-col gap-4"
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
}}
> >
{/* Header */} {errors.name?.type === "manual" && (
<div <Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
style={{ )}
display: "flex",
alignItems: "center", <Input
justifyContent: "space-between", label={`اسم العميل ${isNew ? "*" : ""}`}
padding: "1.25rem 1.5rem", placeholder="شركة لوجي فلو للتوصيل"
borderBottom: "1px solid var(--color-border)", autoComplete="organization"
background: "var(--color-surface-muted)", dir="rtl"
}} error={errors.name?.type !== "manual" ? errors.name?.message : undefined}
> {...register("name")}
<div> />
<p
style={{ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
fontSize: 11, <Input
letterSpacing: "0.3em", label="البريد الإلكتروني"
textTransform: "uppercase", type="email"
color: "#2563EB", placeholder="info@company.sa"
fontWeight: 600, autoComplete="email"
margin: 0, dir="ltr"
}} error={errors.email?.message}
> {...register("email")}
{isNew ? "إضافة عميل" : "تعديل عميل"} />
</p>
<h2 <Input
id="client-modal-title" label={`رقم الهاتف ${isNew ? "*" : ""}`}
style={{ type="tel"
fontSize: 17, placeholder="05xxxxxxxx"
fontWeight: 700, autoComplete="tel"
color: "var(--color-text-primary)", dir="ltr"
margin: "4px 0 0", error={errors.phone?.message}
}} {...register("phone")}
> />
{isNew ? "عميل جديد" : editClient?.name}
</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> </div>
{/* Body */} <Select
<form label="نوع العميل"
onSubmit={handleSubmit(submitHandler)} dir="rtl"
noValidate error={errors.clientType?.message}
style={{ {...register("clientType")}
padding: "1.5rem",
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
> >
{errors.name?.type === "manual" && ( <option value="">اختر النوع</option>
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} /> {CLIENT_TYPES.map((t) => (
)} <option key={t} value={t}>
{t === "Individual" ? "فرد" : "شركة"}
</option>
))}
</Select>
<label style={S.label}> {!isNew && (
اسم العميل {isNew && "*"} <label className="flex items-center gap-2 text-[12px] font-semibold text-[var(--color-text-secondary)] cursor-pointer">
<input <input
{...register("name")} type="checkbox"
style={withError(!!errors.name)} {...register("isActive" as never)}
placeholder="شركة لوجي فلو للتوصيل" defaultChecked={editClient?.isActive ?? true}
autoComplete="organization" className="w-3.5 h-3.5 cursor-pointer"
dir="rtl"
/> />
{errors.name && errors.name.type !== "manual" && ( عميل نشط
<span style={S.errorText}>{errors.name.message}</span>
)}
</label> </label>
)}
<div </form>
style={{ </Modal>
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
<label style={S.label}>
البريد الإلكتروني
<input
{...register("email")}
style={withError(!!errors.email)}
type="email"
placeholder="info@company.sa"
autoComplete="email"
dir="ltr"
/>
{errors.email && (
<span style={S.errorText}>{errors.email.message}</span>
)}
</label>
<label style={S.label}>
رقم الهاتف {isNew && "*"}
<input
{...register("phone")}
style={withError(!!errors.phone)}
type="tel"
placeholder="05xxxxxxxx"
autoComplete="tel"
dir="ltr"
/>
{errors.phone && (
<span style={S.errorText}>{errors.phone.message}</span>
)}
</label>
</div>
<label style={S.label}>
نوع العميل
<select
{...register("clientType")}
style={{ ...withError(!!errors.clientType), cursor: "pointer" }}
dir="rtl"
>
<option value="">اختر النوع</option>
{CLIENT_TYPES.map((t) => (
<option key={t} value={t}>
{t === "Individual" ? "فرد" : "شركة"}
</option>
))}
</select>
{errors.clientType && (
<span style={S.errorText}>{errors.clientType.message}</span>
)}
</label>
{!isNew && (
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer",
}}
>
<input
type="checkbox"
{...register("isActive" as never)}
defaultChecked={editClient?.isActive ?? true}
style={{ width: 14, height: 14, cursor: "pointer" }}
/>
عميل نشط
</label>
)}
<div
style={{
display: "flex",
gap: "0.5rem",
justifyContent: "flex-end",
paddingTop: "0.5rem",
}}
>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
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: isSubmitting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء
</button>
<button
type="submit"
disabled={isSubmitting}
style={{
height: 40,
padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "none",
background: isSubmitting
? "var(--color-brand-400)"
: "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: isSubmitting ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{isSubmitting && <Spinner size="sm" className="text-white" />}
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة العميل" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
); );
} }

View File

@@ -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 { 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,66 +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>
); );
} }
// ── Icon button ──────────────────────────────────────────────────────────── // ── Client detail panel (the expandable content) ───────────────────────────
function IconBtn({
onClick,
title,
color,
bg,
borderColor,
children,
}: {
onClick: () => void;
title: string;
color: string;
bg: string;
borderColor: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{
width: 32,
height: 32,
borderRadius: "var(--radius-md)",
border: `1px solid ${borderColor}`,
background: bg,
color,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
transition: "opacity 150ms",
}}
>
{children}
</button>
);
}
// ── Client detail panel (inline expandable) ────────────────────────────────
/**
* 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,
@@ -126,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",
@@ -139,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",
@@ -179,63 +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"
onClick={(e) => { e.stopPropagation(); onEdit(); }} variant="secondary"
style={{ size="sm"
height: 32, onClick={(e) => {
padding: "0 0.875rem", e.stopPropagation();
borderRadius: "var(--radius-md)", onEdit();
border: "1px solid #BFDBFE",
background: "#EFF6FF",
fontSize: 12,
fontWeight: 600,
color: "#1D4ED8",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 6,
fontFamily: "var(--font-sans)",
}} }}
> >
<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" /> </Button>
</svg> <Button
تعديل البيانات
</button>
{/*
* "Addresses" button — KEY FEATURE:
* Navigates to /dashboard/clients/[clientId]/addresses
*/}
<button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); onGoAddresses(); }} variant="primary"
style={{ size="sm"
height: 32, onClick={(e) => {
padding: "0 0.875rem", e.stopPropagation();
borderRadius: "var(--radius-md)", onGoAddresses();
border: "none",
background: "var(--color-brand-600)",
fontSize: 12,
fontWeight: 700,
color: "#FFF",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 6,
fontFamily: "var(--font-sans)",
boxShadow: "0 1px 4px rgba(37,99,235,.3)",
}} }}
> >
<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
@@ -251,11 +178,10 @@ function ClientDetailPanel({
{client.addresses.length} {client.addresses.length}
</span> </span>
)} )}
</button> </Button>
</div> </div>
</div> </div>
{/* ── Info grid ── */}
<div <div
style={{ style={{
display: "grid", display: "grid",
@@ -266,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()}
@@ -275,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); }
@@ -306,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[];
@@ -337,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;
} }
@@ -354,334 +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)",
return ( margin: 0,
<div style={cardStyle}> display: "flex",
{/* Column headers */} alignItems: "center",
<div gap: 6,
dir="rtl" }}
style={{ >
display: "grid", {c.name}
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px", <i
...thStyle, className="ti ti-chevron-right"
}} aria-hidden="true"
>
<span>العميل</span>
<span>البريد الإلكتروني</span>
<span>الهاتف</span>
<span>العناوين</span>
<span style={{ textAlign: "center" }}>تاريخ الإضافة</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* Loading */}
{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 */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search
? `لا توجد نتائج لـ "${search}"`
: "لا يوجد عملاء لعرضهم."}
</p>
{!search && (
<button
type="button"
onClick={onAddFirst}
style={{ style={{
marginTop: 12, fontSize: 12,
fontSize: 13, flexShrink: 0,
fontWeight: 600, color: "var(--color-text-muted)",
color: "var(--color-brand-600)", transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
background: "none", transition: "transform 200ms",
border: "none", display: "inline-block",
cursor: "pointer", }}
textDecoration: "underline", />
</p>
{c.taxId && (
<p
style={{
marginTop: 2,
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--color-text-muted)",
}} }}
> >
أضف أول عميل {c.taxId}
</button> </p>
)} )}
</div> </div>
) : ( ),
/* Data rows */ },
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> {
{clients.map((c, i) => { key: "email",
const isExpanded = expandedId === c.id; header: "البريد الالكتروني",
return ( width: "1.5fr",
<li key={c.id}> render: (c) => (
{/* ── Main row ── */} <span
<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={{ style={{
display: "flex", fontFamily: "var(--font-mono)",
alignItems: "center", fontSize: 12,
justifyContent: "space-between", color: "#2563EB",
borderTop: "1px solid var(--color-border)", fontWeight: 600,
padding: "0.875rem 1.5rem",
}} }}
> >
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}> {c.email}
صفحة{" "} </span>
<strong style={{ color: "var(--color-text-primary)" }}>{page}</strong>{" "} ),
من{" "} },
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> {
</span> key: "phone",
<div style={{ display: "flex", gap: "0.5rem" }}> header: "رقم الجوال",
{[ width: "1.5fr",
{ render: (c) => (
label: "السابق", <span style={{ color: "var(--color-text-secondary)" }}>{c.phone}</span>
action: () => onPageChange(Math.max(1, page - 1)), ),
disabled: page === 1, },
}, {
{ key: "addresses",
label: "التالي", header: "العناوين",
action: () => onPageChange(Math.min(pages, page + 1)), width: "1fr",
disabled: page === pages, render: (c) => (
}, <AddressBadge
].map((btn) => ( count={c.addresses?.length ?? 0}
<button onClick={() => onManageAddresses(c)}
key={btn.label} />
type="button" ),
onClick={btn.action} },
disabled={btn.disabled} {
style={{ key: "createdAt",
borderRadius: "var(--radius-lg)", header: "تاريخ الانشاء",
border: "1px solid var(--color-border)", width: "1fr",
background: "var(--color-surface-muted)", align: "center",
padding: "0.375rem 0.875rem", render: (c) => (
fontSize: 12, <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
color: "var(--color-text-secondary)", {new Date(c.createdAt).toLocaleDateString("en-US", {
cursor: btn.disabled ? "not-allowed" : "pointer", year: "numeric",
opacity: btn.disabled ? 0.4 : 1, month: "short",
fontFamily: "var(--font-sans)", day: "numeric",
}} })}
> </span>
{btn.label} ),
</button> },
))} ];
</div>
</div> return (
<ReusableTable
columns={columns}
data={clients}
loading={loading}
search={search}
page={page}
pages={pages}
onPageChange={onPageChange}
actionsWidth="80px"
emptyIcon="👥"
emptyTitle={
search ? `No results for "${search}"` : "No clients to display."
}
emptyAction={
!search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
Add first client
</Button>
)
}
renderActions={(c) => (
<>
<IconBtn
title={`Edit ${c.name}`}
color="#1D4ED8"
bg="#EFF6FF"
borderColor="#BFDBFE"
onClick={() => onEdit(c)}
>
<i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
</IconBtn>
<IconBtn
title={`Delete ${c.name}`}
color="#DC2626"
bg="#FEF2F2"
borderColor="#FECACA"
onClick={() => onDelete(c)}
>
<i className="ti ti-trash" style={{ fontSize: 14 }} aria-hidden="true" />
</IconBtn>
</>
)} )}
</div> renderExpanded={(c) => (
<ClientDetailPanel
client={c}
onGoAddresses={() => onManageAddresses(c)}
onEdit={() => onEdit(c)}
/>
)}
/>
); );
} }

View 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>
);
}

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useEffect } from "react"; import { Alert, Badge, Button, Modal, Spinner } from "../../UI";
import { Spinner } from "../../UI";
import { useArchivedClient } from "@/src/hooks/archive/useArchiveClient"; import { useArchivedClient } from "@/src/hooks/archive/useArchiveClient";
interface ArchivedClientDetailModalProps { interface ArchivedClientDetailModalProps {
@@ -9,7 +8,7 @@ interface ArchivedClientDetailModalProps {
onClose: () => void; onClose: () => void;
} }
// ── small helper components ─────────────────────────────────────────────────── // ── small helper components (no template exists for a plain label/value row) ──
function DetailRow({ label, value }: { label: string; value?: string | null }) { function DetailRow({ label, value }: { label: string; value?: string | null }) {
return ( return (
<div style={{ <div style={{
@@ -27,23 +26,7 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
); );
} }
function StatusBadge({ active }: { active: boolean }) { // Avatar has no equivalent in the shared UI kit — kept custom.
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
function Avatar({ name }: { name: string }) { function Avatar({ name }: { name: string }) {
const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase(); const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase();
return ( return (
@@ -60,22 +43,6 @@ function Avatar({ name }: { name: string }) {
); );
} }
// Small badge for an archived order row's status
function OrderStatusBadge({ status }: { status: string }) {
return (
<span style={{
fontSize: 10, fontWeight: 700, textTransform: "uppercase",
borderRadius: "var(--radius-full)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
color: "var(--color-text-muted)",
padding: "0.15rem 0.5rem",
}}>
{status}
</span>
);
}
// ── main component ──────────────────────────────────────────────────────────── // ── main component ────────────────────────────────────────────────────────────
export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientDetailModalProps) { export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientDetailModalProps) {
const { const {
@@ -85,233 +52,158 @@ export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientD
setOrdersPage, setOrdersPage,
} = useArchivedClient(clientId); } = useArchivedClient(clientId);
// close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const fmt = (iso?: string | null) => const fmt = (iso?: string | null) =>
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null; iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null;
// ── render ──────────────────────────────────────────────────────────────── // ── render ────────────────────────────────────────────────────────────────
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="archived-client-detail-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={client?.name ?? "عرض العميل"}
style={{ subtitle="عميل مؤرشف"
position: "fixed", inset: 0, zIndex: 55, onClose={onClose}
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", zIndex={60}
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", size="md"
}} footer={
<Button type="button" variant="secondary" onClick={onClose}>
إغلاق
</Button>
}
> >
<div {/* loading */}
onClick={e => e.stopPropagation()} {loading && (
style={{ <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
width: "100%", maxWidth: 560, <Spinner size="sm" className="text-blue-600" />
background: "var(--color-surface)", <span style={{ fontSize: 13 }}>جارٍ التحميل</span>
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
}}
>
{/* ── 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: "#EA580C", fontWeight: 600, margin: 0 }}>
عميل مؤرشف
</p>
<h2 id="archived-client-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{client?.name ?? "عرض العميل"}
</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> </div>
)}
{/* ── body ── */} {/* error */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "75vh" }}> {!loading && error && <Alert type="error" message={error} />}
{/* loading */} {/* content */}
{loading && ( {!loading && client && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}> <div dir="rtl">
{/* avatar + name + status row */}
<div style={{
display: "flex", alignItems: "center", gap: "1rem",
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<Avatar name={client.name} />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{client.name}</p>
<div style={{ marginTop: 8 }}>
<Badge label={client.isActive ? "نشط" : "معطل"} color={client.isActive ? "green" : "red"} />
</div>
</div>
</div>
{/* detail rows */}
<DetailRow label="البريد الإلكتروني" value={client.email} />
<DetailRow label="رقم الهاتف" value={client.phone} />
<DetailRow label="الرقم الضريبي" value={client.taxId} />
<DetailRow label="ملاحظات" value={client.notes} />
<DetailRow label="تاريخ الإنشاء" value={fmt(client.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(client.updatedAt)} />
{client.deletedAt && (
<DetailRow label="تاريخ الحذف" value={fmt(client.deletedAt)} />
)}
{/* ── addresses ── */}
<p style={{ marginTop: "1.5rem", fontSize: 11, fontWeight: 700, letterSpacing: "0.15em", textTransform: "uppercase", color: "var(--color-text-muted)" }}>
العناوين ({client.addresses?.length ?? 0})
</p>
{!client.addresses || client.addresses.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--color-text-hint)", padding: "0.75rem 0" }}>
لا توجد عناوين مرتبطة بهذا العميل.
</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8, paddingTop: 8 }}>
{client.addresses.map(addr => (
<div key={addr.id} style={{
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-md)",
padding: "0.625rem 0.875rem",
fontSize: 12, color: "var(--color-text-secondary)",
}}>
<strong style={{ color: "var(--color-text-primary)" }}>{addr.label}</strong>
{" — "}{addr.details?.street}، {addr.details?.city}، {addr.details?.state}، {addr.details?.country}
</div>
))}
</div>
)}
{/* ── archived orders ── */}
<p style={{ marginTop: "1.5rem", fontSize: 11, fontWeight: 700, letterSpacing: "0.15em", textTransform: "uppercase", color: "var(--color-text-muted)" }}>
الطلبات المؤرشفة ({ordersTotal})
</p>
{ordersLoading ? (
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "1rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" /> <Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> <span style={{ fontSize: 12 }}>جارٍ تحميل الطلبات</span>
</div> </div>
)} ) : ordersError ? (
<div style={{ fontSize: 12, color: "#991B1B", padding: "0.5rem 0" }}>{ordersError}</div>
) : orders.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--color-text-hint)", padding: "0.75rem 0" }}>
لا توجد طلبات مؤرشفة لهذا العميل.
</p>
) : (
<>
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 6, paddingTop: 8 }}>
{orders.map(o => (
<li key={o.id} style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-md)",
padding: "0.5rem 0.875rem",
fontSize: 12,
}}>
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>
{o.orderNumber ?? o.id}
</span>
<Badge label={o.status} color="slate" />
<span style={{ color: "var(--color-text-muted)" }}>
{new Date(o.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
</li>
))}
</ul>
{/* error */} {ordersPages > 1 && (
{!loading && error && ( <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", paddingTop: 10 }}>
<div style={{ <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
padding: "1rem 1.25rem", صفحة {ordersPage} من {ordersPages}
borderRadius: "var(--radius-lg)", </span>
background: "#FEF2F2", border: "1px solid #FECACA", <div style={{ display: "flex", gap: 6 }}>
fontSize: 13, color: "#991B1B", fontWeight: 500, <Button
textAlign: "center", type="button"
}}> variant="secondary"
{error} size="sm"
</div> disabled={ordersPage === 1}
)} onClick={() => setOrdersPage(Math.max(1, ordersPage - 1))}
>
{/* content */} السابق
{!loading && client && ( </Button>
<div dir="rtl"> <Button
{/* avatar + name + status row */} type="button"
<div style={{ variant="secondary"
display: "flex", alignItems: "center", gap: "1rem", size="sm"
padding: "0 0 1.25rem", disabled={ordersPage === ordersPages}
borderBottom: "1px solid var(--color-border)", onClick={() => setOrdersPage(Math.min(ordersPages, ordersPage + 1))}
marginBottom: "0.25rem", >
}}> التالي
<Avatar name={client.name} /> </Button>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{client.name}</p>
<div style={{ marginTop: 8 }}>
<StatusBadge active={client.isActive} />
</div> </div>
</div> </div>
</div>
{/* detail rows */}
<DetailRow label="البريد الإلكتروني" value={client.email} />
<DetailRow label="رقم الهاتف" value={client.phone} />
<DetailRow label="الرقم الضريبي" value={client.taxId} />
<DetailRow label="ملاحظات" value={client.notes} />
<DetailRow label="تاريخ الإنشاء" value={fmt(client.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(client.updatedAt)} />
{client.deletedAt && (
<DetailRow label="تاريخ الحذف" value={fmt(client.deletedAt)} />
)} )}
</>
{/* ── addresses ── */}
<p style={{ marginTop: "1.5rem", fontSize: 11, fontWeight: 700, letterSpacing: "0.15em", textTransform: "uppercase", color: "var(--color-text-muted)" }}>
العناوين ({client.addresses?.length ?? 0})
</p>
{!client.addresses || client.addresses.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--color-text-hint)", padding: "0.75rem 0" }}>
لا توجد عناوين مرتبطة بهذا العميل.
</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8, paddingTop: 8 }}>
{client.addresses.map(addr => (
<div key={addr.id} style={{
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-md)",
padding: "0.625rem 0.875rem",
fontSize: 12, color: "var(--color-text-secondary)",
}}>
<strong style={{ color: "var(--color-text-primary)" }}>{addr.label}</strong>
{" — "}{addr.street}، {addr.city}
</div>
))}
</div>
)}
{/* ── archived orders ── */}
<p style={{ marginTop: "1.5rem", fontSize: 11, fontWeight: 700, letterSpacing: "0.15em", textTransform: "uppercase", color: "var(--color-text-muted)" }}>
الطلبات المؤرشفة ({ordersTotal})
</p>
{ordersLoading ? (
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "1rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 12 }}>جارٍ تحميل الطلبات</span>
</div>
) : ordersError ? (
<div style={{ fontSize: 12, color: "#991B1B", padding: "0.5rem 0" }}>{ordersError}</div>
) : orders.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--color-text-hint)", padding: "0.75rem 0" }}>
لا توجد طلبات مؤرشفة لهذا العميل.
</p>
) : (
<>
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 6, paddingTop: 8 }}>
{orders.map(o => (
<li key={o.id} style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-md)",
padding: "0.5rem 0.875rem",
fontSize: 12,
}}>
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>
{o.orderNumber ?? o.id}
</span>
<OrderStatusBadge status={o.status} />
<span style={{ color: "var(--color-text-muted)" }}>
{new Date(o.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
</li>
))}
</ul>
{ordersPages > 1 && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", paddingTop: 10 }}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
صفحة {ordersPage} من {ordersPages}
</span>
<div style={{ display: "flex", gap: 6 }}>
<button type="button" disabled={ordersPage === 1}
onClick={() => setOrdersPage(Math.max(1, ordersPage - 1))}
style={{ fontSize: 11, padding: "0.25rem 0.625rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", cursor: ordersPage === 1 ? "not-allowed" : "pointer", opacity: ordersPage === 1 ? 0.4 : 1 }}>
السابق
</button>
<button type="button" disabled={ordersPage === ordersPages}
onClick={() => setOrdersPage(Math.min(ordersPages, ordersPage + 1))}
style={{ fontSize: 11, padding: "0.25rem 0.625rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", cursor: ordersPage === ordersPages ? "not-allowed" : "pointer", opacity: ordersPage === ordersPages ? 0.4 : 1 }}>
التالي
</button>
</div>
</div>
)}
</>
)}
</div>
)} )}
</div> </div>
)}
{/* ── footer ── */} </Modal>
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", justifyContent: "flex-end",
}}>
<button
type="button" onClick={onClose}
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>
</div>
</div>
); );
} }

View File

@@ -1,134 +1,76 @@
"use client"; "use client";
import { 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";
// ── status badge ─────────────────────────────────────────────────────────────
function StatusBadge({ active }: { active: boolean }) {
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={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
// ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── 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 */
<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 }}>
{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>
<StatusBadge active={c.isActive} />
<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" 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>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "../../UI"; import { Alert, Input, Modal } from "../../UI";
import { ArchivedClientTable } from "./ArchivedClientTable"; import { ArchivedClientTable } from "./ArchivedClientTable";
import { ArchivedClientDetailModal } from "./ArchivedClientDetailModal"; import { ArchivedClientDetailModal } from "./ArchivedClientDetailModal";
import { useArchivedClients } from "@/src/hooks/archive/useArchivedClients"; import { useArchivedClients } from "@/src/hooks/archive/useArchivedClients";
@@ -20,95 +20,45 @@ export function ArchivedClientsModal({ onClose }: ArchivedClientsModalProps) {
} = useArchivedClients(); } = useArchivedClients();
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="archive-client-modal-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={`العملاء المؤرشفون (${total})`}
style={{ subtitle="الأرشيف"
position: "fixed", inset: 0, zIndex: 70, onClose={onClose}
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", size="lg"
display: "flex", alignItems: "flex-start", justifyContent: "center",
padding: "2rem 1rem", overflowY: "auto",
}}
> >
{viewClientId && ( {viewClientId && (
<ArchivedClientDetailModal clientId={viewClientId} onClose={() => setViewClientId(null)} /> <ArchivedClientDetailModal clientId={viewClientId} onClose={() => setViewClientId(null)} />
)} )}
<div <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
onClick={e => e.stopPropagation()} {/* search */}
style={{ <div style={{ maxWidth: 320 }}>
width: "100%", maxWidth: 920, <Input
background: "var(--color-surface-sunken)", label="بحث"
borderRadius: "var(--radius-xl)", type="text"
border: "1px solid var(--color-border)", placeholder="بحث بالاسم أو البريد الإلكتروني..."
boxShadow: "0 24px 64px rgba(0,0,0,.2)", value={search}
overflow: "hidden", onChange={e => handleSearch(e.target.value)}
}} dir="rtl"
> icon={
{/* header */} <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
<div dir="rtl" style={{ }
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-client-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
العملاء المؤرشفون
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</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 */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
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)}
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, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/>
</div>
{error && <Alert type="error" message={error} onClose={clearError} />}
<ArchivedClientTable
clients={clients}
loading={loading}
search={search}
page={page}
pages={pages}
onView={client => setViewClientId(client.id)}
onPageChange={setPage}
/> />
</div> </div>
{error && <Alert type="error" message={error} onClose={clearError} />}
<ArchivedClientTable
clients={clients}
loading={loading}
search={search}
page={page}
pages={pages}
onView={client => setViewClientId(client.id)}
onPageChange={setPage}
/>
</div> </div>
</div> </Modal>
); );
} }

View File

@@ -1,6 +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";
export { Toast } from "./Toast";

View 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>
);
}

View File

@@ -1,9 +1,8 @@
"use client"; "use client";
import { useEffect } from "react";
import { useForm, type Resolver } from "react-hook-form"; import { useForm, type Resolver } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Spinner } from "../UI"; import { Alert, Button, Input, Modal } from "../UI";
import { import {
createAddressSchema, createAddressSchema,
updateAddressSchema, updateAddressSchema,
@@ -13,32 +12,9 @@ import {
import type { ClientAddress } from "@/src/types/client_adresses"; import type { ClientAddress } from "@/src/types/client_adresses";
// ── Styles ───────────────────────────────────────────────────────────────── // ── Styles ─────────────────────────────────────────────────────────────────
// Only section-title styling is left here — every input/label/error visual
// is now owned by the shared <Input /> component.
const S = { 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,
sectionTitle: { sectionTitle: {
fontSize: 11, fontSize: 11,
fontWeight: 700, fontWeight: 700,
@@ -50,11 +26,7 @@ const S = {
} as React.CSSProperties, } as React.CSSProperties,
}; };
const withError = (hasError: boolean): React.CSSProperties => ({ const FORM_ID = "address-form";
...S.input,
border: hasError ? "1px solid var(--color-danger)" : "1px solid var(--color-border)",
background: hasError ? "#FEF2F2" : S.input.background,
});
// ── Props ────────────────────────────────────────────────────────────────── // ── Props ──────────────────────────────────────────────────────────────────
interface AddressFormModalProps { interface AddressFormModalProps {
@@ -138,12 +110,6 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
location: locationErr = {}, location: locationErr = {},
} = errors as AddressErrors; } = errors as AddressErrors;
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const submitHandler = async (data: CreateAddressFormValues | UpdateAddressFormValues) => { const submitHandler = async (data: CreateAddressFormValues | UpdateAddressFormValues) => {
const ok = await onSubmit(data); const ok = await onSubmit(data);
if (ok) { if (ok) {
@@ -154,260 +120,178 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
}; };
return ( return (
<div <Modal
role="dialog" open
aria-modal="true" title={isNew ? "عنوان جديد" : editAddress?.label ?? ""}
aria-labelledby="addr-modal-title" subtitle={isNew ? "إضافة عنوان" : "تعديل عنوان"}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} onClose={onClose}
style={{ size="md"
position: "fixed", footer={
inset: 0, <>
zIndex: 50, <Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
background: "rgba(15,23,42,0.55)", إلغاء
backdropFilter: "blur(4px)", </Button>
display: "flex", <Button type="submit" form={FORM_ID} loading={isSubmitting}>
alignItems: "center", {isNew ? "إضافة العنوان" : "حفظ التغييرات"}
justifyContent: "center", </Button>
padding: "1rem", </>
}} }
> >
<div <form
onClick={(e) => e.stopPropagation()} id={FORM_ID}
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", noValidate
maxWidth: 560, style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
maxHeight: "90vh",
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",
display: "flex",
flexDirection: "column",
}}
> >
{/* Header */} {errors.label?.type === "manual" && (
<div <Alert type="error" message={errors.label.message ?? ""} onClose={() => {}} />
style={{ )}
flexShrink: 0,
display: "flex", {/* Address Meta */}
alignItems: "center", <p style={S.sectionTitle}>بيانات العنوان</p>
justifyContent: "space-between",
padding: "1.25rem 1.5rem", <Input
borderBottom: "1px solid var(--color-border)", label={`نوع العنوان${isNew ? " *" : ""}`}
background: "var(--color-surface-muted)", {...register("label")}
}} error={errors.label && errors.label.type !== "manual" ? errors.label.message : undefined}
> placeholder="مثال: منزل / مكتب / شحن"
<div> dir="rtl"
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> />
{isNew ? "إضافة عنوان" : "تعديل عنوان"}
</p> <Input
<h2 label="اسم الفرع (اختياري)"
id="addr-modal-title" {...register("branchName")}
style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }} placeholder="فرع الرياض"
> dir="rtl"
{isNew ? "عنوان جديد" : editAddress?.label} />
</h2>
</div> {/* Address Details */}
<button <p style={S.sectionTitle}>تفاصيل العنوان</p>
type="button"
onClick={onClose} <Input
aria-label="إغلاق" label={`الشارع / العنوان التفصيلي${isNew ? " *" : ""}`}
style={{ {...register("details.street")}
width: 34, error={detailsErr.street?.message}
height: 34, placeholder="شارع الملك فهد، مبنى 12"
borderRadius: "var(--radius-md)", dir="rtl"
border: "1px solid var(--color-border)", />
background: "var(--color-surface)",
cursor: "pointer", <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
fontSize: 18, <Input
color: "var(--color-text-muted)", label={`المدينة${isNew ? " *" : ""}`}
display: "flex", {...register("details.city")}
alignItems: "center", error={detailsErr.city?.message}
justifyContent: "center", placeholder="الرياض"
}} dir="rtl"
> />
× <Input
</button> label="المنطقة"
{...register("details.state")}
error={detailsErr.state?.message}
placeholder="منطقة الرياض"
dir="rtl"
/>
</div> </div>
{/* Scrollable body */} <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<div style={{ overflowY: "auto", flex: 1 }}> <Input
<form label="الحي (اختياري)"
onSubmit={handleSubmit(submitHandler)} {...register("details.district")}
noValidate placeholder="حي العليا"
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }} dir="rtl"
> />
{errors.label?.type === "manual" && ( <Input
<Alert type="error" message={errors.label.message ?? ""} onClose={() => {}} /> label="رقم المبنى (اختياري)"
)} {...register("details.buildingNo")}
placeholder="1234"
{/* Address Meta */} dir="ltr"
<p style={S.sectionTitle}>بيانات العنوان</p> />
<label style={S.label}>
نوع العنوان
<input
{...register("label")}
style={withError(!!errors.label && errors.label.type !== "manual")}
placeholder="مثال: منزل / مكتب / شحن"
dir="rtl"
/>
{errors.label && errors.label.type !== "manual" && (
<span style={S.errorText}>{errors.label.message}</span>
)}
</label>
<label style={S.label}>
اسم الفرع (اختياري)
<input {...register("branchName")} style={S.input} placeholder="فرع الرياض" dir="rtl" />
</label>
{/* Address Details */}
<p style={S.sectionTitle}>تفاصيل العنوان</p>
<label style={S.label}>
الشارع / العنوان التفصيلي {isNew && "*"}
<input
{...register("details.street")}
style={withError(!!detailsErr.street)}
placeholder="شارع الملك فهد، مبنى 12"
dir="rtl"
/>
{detailsErr.street && <span style={S.errorText}>{detailsErr.street.message}</span>}
</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
المدينة {isNew && "*"}
<input {...register("details.city")} style={withError(!!detailsErr.city)} placeholder="الرياض" dir="rtl" />
{detailsErr.city && <span style={S.errorText}>{detailsErr.city.message}</span>}
</label>
<label style={S.label}>
المنطقة
<input {...register("details.state")} style={withError(!!detailsErr.state)} placeholder="منطقة الرياض" dir="rtl" />
{detailsErr.state && <span style={S.errorText}>{detailsErr.state.message}</span>}
</label>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
الحي (اختياري)
<input {...register("details.district")} style={S.input} placeholder="حي العليا" dir="rtl" />
</label>
<label style={S.label}>
رقم المبنى (اختياري)
<input {...register("details.buildingNo")} style={S.input} placeholder="1234" dir="ltr" />
</label>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
رقم الوحدة (اختياري)
<input {...register("details.unitNo")} style={S.input} placeholder="5678" dir="ltr" />
</label>
<label style={S.label}>
الرقم الإضافي (اختياري)
<input {...register("details.additionalNo")} style={S.input} placeholder="0000" dir="ltr" />
</label>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
الرمز البريدي (اختياري)
<input {...register("details.zipCode")} style={withError(!!detailsErr.zipCode)} placeholder="11564" dir="ltr" />
{detailsErr.zipCode && <span style={S.errorText}>{detailsErr.zipCode.message}</span>}
</label>
<label style={S.label}>
الدولة
<input {...register("details.country")} style={withError(!!detailsErr.country)} placeholder="SA" dir="ltr" />
{detailsErr.country && <span style={S.errorText}>{detailsErr.country.message}</span>}
</label>
</div>
<label style={S.label}>
الشقة / الطابق (اختياري)
<input {...register("details.apartment")} style={S.input} placeholder="الطابق الثالث" dir="rtl" />
</label>
{/* Contact Person */}
<p style={S.sectionTitle}>جهة الاتصال (اختياري)</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
الاسم
<input {...register("contactPerson.name")} style={withError(!!contactErr.name)} placeholder="أحمد محمد" dir="rtl" />
{contactErr.name && <span style={S.errorText}>{contactErr.name.message}</span>}
</label>
<label style={S.label}>
رقم الهاتف
<input {...register("contactPerson.phone")} style={withError(!!contactErr.phone)} type="tel" placeholder="05xxxxxxxx" dir="ltr" />
{contactErr.phone && <span style={S.errorText}>{contactErr.phone.message}</span>}
</label>
</div>
{/* Coordinates */}
<p style={S.sectionTitle}>الإحداثيات الجغرافية {isNew && "*"}</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
خط الطول (Longitude) {isNew && "*"}
<input {...register("location.coordinates.0" as never)} style={withError(!!locationErr.coordinates)} type="number" step="any" placeholder="46.6753" dir="ltr" />
</label>
<label style={S.label}>
خط العرض (Latitude) {isNew && "*"}
<input {...register("location.coordinates.1" as never)} style={withError(!!locationErr.coordinates)} type="number" step="any" placeholder="24.7136" dir="ltr" />
{locationErr.coordinates && <span style={S.errorText}>{locationErr.coordinates.message}</span>}
</label>
</div>
{/* Actions */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
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: isSubmitting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء
</button>
<button
type="submit"
disabled={isSubmitting}
style={{
height: 40,
padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "none",
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: isSubmitting ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{isSubmitting && <Spinner size="sm" className="text-white" />}
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة العنوان" : "حفظ التغييرات"}
</button>
</div>
</form>
</div> </div>
</div>
</div> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input
label="رقم الوحدة (اختياري)"
{...register("details.unitNo")}
placeholder="5678"
dir="ltr"
/>
<Input
label="الرقم الإضافي (اختياري)"
{...register("details.additionalNo")}
placeholder="0000"
dir="ltr"
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input
label="الرمز البريدي (اختياري)"
{...register("details.zipCode")}
error={detailsErr.zipCode?.message}
placeholder="11564"
dir="ltr"
/>
<Input
label="الدولة"
{...register("details.country")}
error={detailsErr.country?.message}
placeholder="SA"
dir="ltr"
/>
</div>
<Input
label="الشقة / الطابق (اختياري)"
{...register("details.apartment")}
placeholder="الطابق الثالث"
dir="rtl"
/>
{/* Contact Person */}
<p style={S.sectionTitle}>جهة الاتصال (اختياري)</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input
label="الاسم"
{...register("contactPerson.name")}
error={contactErr.name?.message}
placeholder="أحمد محمد"
dir="rtl"
/>
<Input
label="رقم الهاتف"
{...register("contactPerson.phone")}
error={contactErr.phone?.message}
type="tel"
placeholder="05xxxxxxxx"
dir="ltr"
/>
</div>
{/* Coordinates */}
<p style={S.sectionTitle}>الإحداثيات الجغرافية{isNew ? " *" : ""}</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input
label={`خط الطول (Longitude)${isNew ? " *" : ""}`}
{...register("location.coordinates.0" as never)}
error={locationErr.coordinates?.message}
type="number"
step="any"
placeholder="46.6753"
dir="ltr"
/>
<Input
label={`خط العرض (Latitude)${isNew ? " *" : ""}`}
{...register("location.coordinates.1" as never)}
error={locationErr.coordinates?.message}
type="number"
step="any"
placeholder="24.7136"
dir="ltr"
/>
</div>
</form>
</Modal>
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect } from "react"; import { Button, Modal } from "../../UI";
import type { ArchivedClientAddress } from "@/src/types/client_adresses"; import type { ArchivedClientAddress } from "@/src/types/client_adresses";
interface ArchivedClientAddressesDetailModalProps { interface ArchivedClientAddressesDetailModalProps {
@@ -8,7 +8,7 @@ interface ArchivedClientAddressesDetailModalProps {
onClose: () => void; onClose: () => void;
} }
// ── small helper components ─────────────────────────────────────────────────── // ── small helper components (no template exists for a plain label/value row) ──
function DetailRow({ label, value }: { label: string; value?: string | null }) { function DetailRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null; if (!value) return null;
return ( return (
@@ -42,131 +42,61 @@ function SectionHeader({ title }: { title: string }) {
// ── main component ──────────────────────────────────────────────────────────── // ── main component ────────────────────────────────────────────────────────────
export function ArchivedClientAddressesDetailModal({ address, onClose }: ArchivedClientAddressesDetailModalProps) { export function ArchivedClientAddressesDetailModal({ address, onClose }: ArchivedClientAddressesDetailModalProps) {
// close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const { details, contactPerson, location } = address; const { details, contactPerson, location } = address;
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="archived-address-detail-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={address.label}
style={{ subtitle="عنوان مؤرشف"
position: "fixed", inset: 0, zIndex: 80, onClose={onClose}
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", zIndex={60}
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", size="md"
}} footer={
<Button type="button" variant="secondary" onClick={onClose}>
إغلاق
</Button>
}
> >
<div <div dir="rtl">
onClick={e => e.stopPropagation()} <DetailRow label="معرف العميل" value={address.clientId} />
style={{ <DetailRow label="الفرع" value={address.branchName} />
width: "100%", maxWidth: 480,
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",
display: "flex", flexDirection: "column",
}}
>
{/* ── 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: "#EA580C", fontWeight: 600, margin: 0 }}>
عنوان مؤرشف
</p>
<h2 id="archived-address-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{address.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 ── */} <SectionHeader title="تفاصيل العنوان" />
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }} dir="rtl"> <DetailRow label="الشارع" value={details.street} />
<DetailRow label="معرف العميل" value={address.clientId} /> <DetailRow label="المدينة" value={details.city} />
<DetailRow label="الفرع" value={address.branchName} /> <DetailRow label="المنطقة" value={details.state} />
<DetailRow label="الحي" value={details.district} />
<DetailRow label="رقم المبنى" value={details.buildingNo} />
<DetailRow label="رقم الوحدة" value={details.unitNo} />
<DetailRow label="الرقم الإضافي" value={details.additionalNo} />
<DetailRow label="الرمز البريدي" value={details.zipCode} />
<DetailRow label="الدولة" value={details.country} />
<SectionHeader title="تفاصيل العنوان" /> {(contactPerson?.name || contactPerson?.phone) && (
<DetailRow label="الشارع" value={details.street} /> <>
<DetailRow label="المدينة" value={details.city} /> <SectionHeader title="جهة الاتصال" />
<DetailRow label="المنطقة" value={details.state} /> <DetailRow label="الاسم" value={contactPerson.name} />
<DetailRow label="الحي" value={details.district} /> <DetailRow label="رقم الهاتف" value={contactPerson.phone} />
<DetailRow label="رقم المبنى" value={details.buildingNo} /> </>
<DetailRow label="رقم الوحدة" value={details.unitNo} /> )}
<DetailRow label="الرقم الإضافي" value={details.additionalNo} />
<DetailRow label="الرمز البريدي" value={details.zipCode} />
<DetailRow label="الدولة" value={details.country} />
{(contactPerson?.name || contactPerson?.phone) && ( {location?.coordinates && (
<> <>
<SectionHeader title="جهة الاتصال" /> <SectionHeader title="الإحداثيات الجغرافية" />
<DetailRow label="الاسم" value={contactPerson.name} /> <DetailRow label="خط الطول (Longitude)" value={String(location.coordinates[0])} />
<DetailRow label="رقم الهاتف" value={contactPerson.phone} /> <DetailRow label="خط العرض (Latitude)" value={String(location.coordinates[1])} />
</> </>
)} )}
{location?.coordinates && ( <SectionHeader title="معلومات النظام" />
<> <DetailRow label="حالة التوثيق" value={address.isValidated ? "موثّق" : "غير موثّق"} />
<SectionHeader title="الإحداثيات الجغرافية" /> <DetailRow label="تاريخ الإنشاء" value={new Date(address.createdAt).toLocaleString("ar-SA")} />
<DetailRow label="خط الطول (Longitude)" value={String(location.coordinates[0])} /> <DetailRow label="آخر تحديث" value={new Date(address.updatedAt).toLocaleString("ar-SA")} />
<DetailRow label="خط العرض (Latitude)" value={String(location.coordinates[1])} /> {address.deletedAt && (
</> <DetailRow label="تاريخ الحذف" value={new Date(address.deletedAt).toLocaleString("ar-SA")} />
)} )}
<SectionHeader title="معلومات النظام" />
<DetailRow label="حالة التوثيق" value={address.isValidated ? "موثّق" : "غير موثّق"} />
<DetailRow label="تاريخ الإنشاء" value={new Date(address.createdAt).toLocaleString("ar-SA")} />
<DetailRow label="آخر تحديث" value={new Date(address.updatedAt).toLocaleString("ar-SA")} />
{address.deletedAt && (
<DetailRow label="تاريخ الحذف" value={new Date(address.deletedAt).toLocaleString("ar-SA")} />
)}
</div>
{/* ── footer ── */}
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", justifyContent: "flex-end",
}}>
<button
type="button" onClick={onClose}
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>
</div> </div>
</div> </Modal>
); );
} }

View File

@@ -1,39 +1,12 @@
"use client"; "use client";
import { 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";
// ── validation badge ─────────────────────────────────────────────────────────
function ValidatedBadge({ validated }: { validated: boolean }) {
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: validated ? "1px solid #BBF7D0" : "1px solid var(--color-border)", background: validated ? "#DCFCE7" : "var(--color-surface-muted)", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: validated ? "#166534" : "var(--color-text-muted)" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: validated ? "#16A34A" : "var(--color-text-hint)" }} />
{validated ? "موثّق" : "غير موثّق"}
</span>
);
}
// ── icon button ────────────────────────────────────────────────────────────── type Row = ArchivedClientAddress & { id: string };
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── 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 ArchivedClientAddressesTableProps { interface ArchivedClientAddressesTableProps {
@@ -45,71 +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 */ },
<div style={{ textAlign: "center", padding: "4rem 1rem" }}> {
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}> key: "address",
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."} header: "العنوان",
</p> width: "2fr",
</div> render: (a) => (
) : ( <span style={{ color: "var(--color-text-secondary)" }}>
/* data rows */ {a.details.street}، {a.details.city}
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> </span>
{addresses.map((a, i) => ( ),
<li key={a._id} style={{ },
display: "grid", gridTemplateColumns: "1.5fr 2fr 1.2fr 1fr 1fr 100px", {
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem", key: "contact",
borderBottom: "1px solid var(--color-border)", header: "جهة الاتصال",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", width: "1.2fr",
fontSize: 13, render: (a) => (
}}> <span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
<div> {a.contactPerson?.name ?? "—"}
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{a.label}</p> </span>
{a.branchName && ( ),
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{a.branchName}</p> },
)} {
</div> key: "status",
<span style={{ color: "var(--color-text-secondary)" }}> header: "الحالة",
{a.details.street}، {a.details.city} width: "1fr",
</span> render: (a) => (
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}> <Badge
{a.contactPerson?.name ?? "—"} label={a.isValidated ? "موثّق" : "غير موثّق"}
</span> color={a.isValidated ? "green" : "slate"}
<ValidatedBadge validated={!!a.isValidated} /> />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}> ),
{new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} },
</span> {
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}> key: "createdAt",
{/* view only — matches archived users/clients: no delete/restore yet */} header: "تاريخ الإنشاء",
<IconBtn title={`عرض ${a.label}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(a)}> width: "1fr",
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> render: (a) => (
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
<circle cx="12" cy="12" r="3" /> {new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</svg> </span>
</IconBtn> ),
</div> },
</li> ];
))}
</ul> return (
<ReusableTable
columns={columns}
data={rows}
loading={loading}
search={search}
page={1}
pages={1}
onPageChange={() => {}}
actionsWidth="100px"
onRowClick={onView}
emptyDescription={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
renderActions={(a) => (
// view only — matches archived users/clients: no delete/restore yet
<IconBtn
title={`عرض ${a.label}`}
color="#059669" bg="#ECFDF5" borderColor="#A7F3D0"
onClick={() => onView(a)}
>
<i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
</IconBtn>
)} )}
</div> />
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "../../UI"; import { Alert, Input, Modal } from "../../UI";
import { ArchivedClientAddressesTable } from "./ArchivedClientAddressesTable"; import { ArchivedClientAddressesTable } from "./ArchivedClientAddressesTable";
import { ArchivedClientAddressesDetailModal } from "./ArchivedClientAddressesDetailModal"; import { ArchivedClientAddressesDetailModal } from "./ArchivedClientAddressesDetailModal";
import { useArchivedClientAddresses } from "@/src/hooks/archive/useArchiveClientAdresses"; import { useArchivedClientAddresses } from "@/src/hooks/archive/useArchiveClientAdresses";
@@ -24,92 +24,42 @@ export function ArchivedClientsAddressesModal({ onClose, clientId }: ArchivedCli
} = useArchivedClientAddresses(clientId); } = useArchivedClientAddresses(clientId);
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="archive-address-modal-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={`العناوين المؤرشفة (${total})`}
style={{ subtitle="الأرشيف"
position: "fixed", inset: 0, zIndex: 70, onClose={onClose}
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", size="lg"
display: "flex", alignItems: "flex-start", justifyContent: "center",
padding: "2rem 1rem", overflowY: "auto",
}}
> >
{viewAddress && ( {viewAddress && (
<ArchivedClientAddressesDetailModal address={viewAddress} onClose={() => setViewAddress(null)} /> <ArchivedClientAddressesDetailModal address={viewAddress} onClose={() => setViewAddress(null)} />
)} )}
<div <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
onClick={e => e.stopPropagation()} {/* search — client-side only: this endpoint has no server-side search/pagination */}
style={{ <div style={{ maxWidth: 320 }}>
width: "100%", maxWidth: 920, <Input
background: "var(--color-surface-sunken)", label="بحث"
borderRadius: "var(--radius-xl)", type="text"
border: "1px solid var(--color-border)", placeholder="بحث بالنوع أو المدينة أو الفرع..."
boxShadow: "0 24px 64px rgba(0,0,0,.2)", value={search}
overflow: "hidden", onChange={e => handleSearch(e.target.value)}
}} dir="rtl"
> icon={
{/* header */} <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
<div dir="rtl" style={{ }
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-address-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
العناوين المؤرشفة
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</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 */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search — client-side only: this endpoint has no server-side search/pagination */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
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)}
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, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/>
</div>
{error && <Alert type="error" message={error} onClose={clearError} />}
<ArchivedClientAddressesTable
addresses={addresses}
loading={loading}
search={search}
onView={setViewAddress}
/> />
</div> </div>
{error && <Alert type="error" message={error} onClose={clearError} />}
<ArchivedClientAddressesTable
addresses={addresses}
loading={loading}
search={search}
onView={setViewAddress}
/>
</div> </div>
</div> </Modal>
); );
} }

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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";

View 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>
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Spinner } from "../UI"; import { Alert, Button, Spinner } from "../UI";
import { driverService } from "@/src/services/driver.service"; import { driverService } from "@/src/services/driver.service";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { DriverReportPanel } from "../Driver_Report/driverReport"; import { DriverReportPanel } from "../Driver_Report/driverReport";
@@ -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 && (
@@ -306,18 +295,7 @@ export function DriverDetailPanel({
</div> </div>
)} )}
{error && ( {error && <Alert type="error" message={error} />}
<div style={{
borderRadius: "var(--radius-md)",
background: "#FEF2F2",
border: "1px solid #FECACA",
padding: "0.75rem 1rem",
fontSize: 13,
color: "#DC2626",
}}>
{error}
</div>
)}
{driver && !loading && ( {driver && !loading && (
<> <>
@@ -462,55 +440,18 @@ export function DriverDetailPanel({
flexShrink: 0, flexShrink: 0,
}} }}
> >
<button <Button type="button" variant="danger" onClick={() => onDelete(driver)}>
type="button"
onClick={() => onDelete(driver)}
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>
<button <Button type="button" variant="secondary" onClick={() => onEdit(driver)}>
type="button" <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
onClick={() => onEdit(driver)}
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>
<button <Button type="button" variant="secondary" fullWidth onClick={onClose}>
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> </Button>
</div> </div>
)} )}
</aside> </aside>

View File

@@ -1,12 +1,16 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Spinner } 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,
@@ -16,37 +20,8 @@ import type {
DriverStatus, DriverStatus,
} from "@/src/types/driver"; } from "@/src/types/driver";
import type { Branch } from "@/src/types/branch"; import type { Branch } from "@/src/types/branch";
import { FileInput } from "@/src/Components/Driver/DriverFormModalHelper";
// ── Shared input style ─────────────────────────────────────────────────────── // ── Shared bits ──────────────────────────────────────────────────────────────
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)",
};
const errorTextStyle: React.CSSProperties = {
fontSize: 11,
color: "var(--color-danger)",
fontWeight: 500,
};
const sectionHeadingStyle: React.CSSProperties = { const sectionHeadingStyle: React.CSSProperties = {
fontSize: 11, fontSize: 11,
@@ -57,12 +32,7 @@ const sectionHeadingStyle: React.CSSProperties = {
margin: "0.5rem 0 0", margin: "0.5rem 0 0",
}; };
const optionalLabelStyle: React.CSSProperties = { const FORM_ID = "driver-form";
fontSize: 10,
fontWeight: 500,
color: "var(--color-text-hint)",
marginRight: 4,
};
// ── Date helper ────────────────────────────────────────────────────────────── // ── Date helper ──────────────────────────────────────────────────────────────
@@ -120,33 +90,28 @@ export function DriverFormModal({
}: DriverFormModalProps) { }: DriverFormModalProps) {
const isNew = editDriver === null; const isNew = editDriver === null;
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
const [branches, setBranches] = useState<Branch[]>(branchesProp);
useEffect(() => {
if (branchesProp.length > 0) {
queueMicrotask(() => setBranches(branchesProp));
return;
}
const token = getStoredToken();
get<{ data: { data: Branch[] } }>("branches?limit=100", token)
.then((res) => {
const list = (res as unknown as { data: { data: Branch[] } }).data?.data ?? [];
setBranches(list);
})
.catch(() => {
/* silently ignore */
});
}, [branchesProp]);
// ── react-hook-form ──────────────────────────────────────────────────────── // ── react-hook-form ────────────────────────────────────────────────────────
// Moved above the branches-loading effect below: that effect calls
// setValue(), so useForm() (which defines it) must run first — otherwise
// setValue is referenced before its own initialization (TDZ ReferenceError),
// same issue already fixed in TripFormModal.
const { const {
register, register,
handleSubmit, handleSubmit,
control, control,
setError, setError,
setFocus,
setValue,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<DriverFormValues>({ } = useForm<DriverFormValues>({
resolver: yupResolver(isNew ? createDriverSchema : updateDriverSchema) as never, // Cast both the schema argument and the resolver's result — same fix
// already applied in TripFormModal: createDriverSchema/updateDriverSchema
// have structurally different required fields (union type yupResolver's
// single-schema signature won't accept), and yup's inferred type for
// .optional() fields is structurally incompatible with DriverFormValues.
resolver: yupResolver(
(isNew ? createDriverSchema : updateDriverSchema) as any,
) as any,
defaultValues: { defaultValues: {
name: editDriver?.name ?? "", name: editDriver?.name ?? "",
phone: editDriver?.phone ?? "", phone: editDriver?.phone ?? "",
@@ -154,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 ?? "",
@@ -173,43 +141,67 @@ export function DriverFormModal({
}, },
}); });
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
const [branches, setBranches] = useState<Branch[]>(branchesProp);
useEffect(() => {
if (branchesProp.length > 0) {
queueMicrotask(() => setBranches(branchesProp));
return;
}
const token = getStoredToken();
get<{ data: { data: Branch[] } }>("branches?limit=100", token)
.then((res) => {
const list =
(res as unknown as { data: { data: Branch[] } }).data?.data ?? [];
setBranches(list);
})
.catch(() => {
/* silently ignore */
});
}, [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("");
const firstRef = useRef<HTMLInputElement>(null);
// register("name") already attaches its own ref — focusing via setFocus
// avoids the ref collision that a separate `ref={firstRef}` would cause.
useEffect(() => { useEffect(() => {
firstRef.current?.focus(); setFocus("name");
}, []); }, [setFocus]);
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
const withError = (hasError: boolean): React.CSSProperties => ({
...inputBase,
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
// ── Submit ──────────────────────────────────────────────────────────────── // ── Submit ────────────────────────────────────────────────────────────────
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;
@@ -221,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);
} }
@@ -240,377 +240,282 @@ export function DriverFormModal({
// ── Render ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
return ( return (
<div <Modal
role="dialog" open
aria-modal="true" onClose={onClose}
aria-labelledby="driver-modal-title" size="lg"
onClick={(e) => { subtitle={isNew ? "إضافة سائق" : "تعديل سائق"}
if (e.target === e.currentTarget) onClose(); title={isNew ? "سائق جديد" : (editDriver?.name ?? "")}
}} footer={
style={{ <>
position: "fixed", inset: 0, zIndex: 50, <Button
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", variant="secondary"
display: "flex", alignItems: "center", justifyContent: "center", type="button"
padding: "1rem", overflowY: "auto", onClick={onClose}
}} disabled={isSubmitting}
>
إلغاء
</Button>
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
{isNew ? "إضافة السائق" : "حفظ التغييرات"}
</Button>
</>
}
> >
<div <form
onClick={(e) => e.stopPropagation()} id={FORM_ID}
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", maxWidth: 640, noValidate
background: "var(--color-surface)", dir="rtl"
borderRadius: "var(--radius-2xl)", className="flex flex-col gap-4"
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden", margin: "auto",
}}
> >
{/* Header */} {errors.name?.type === "manual" && (
<div style={{ <Alert
display: "flex", alignItems: "center", justifyContent: "space-between", type="error"
padding: "1.25rem 1.5rem", message={errors.name.message ?? ""}
borderBottom: "1px solid var(--color-border)", onClose={() => setApiError("")}
background: "var(--color-surface-muted)", />
}}> )}
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> {/* ── Section: Personal Info ── */}
{isNew ? "إضافة سائق" : "تعديل سائق"} <p style={sectionHeadingStyle}>البيانات الشخصية</p>
</p>
<h2 id="driver-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{isNew ? "سائق جديد" : editDriver?.name} <Input
</h2> label="الاسم الكامل *"
</div> placeholder="محمد عبدالله"
<button type="button" onClick={onClose} aria-label="إغلاق" dir="rtl"
style={{ autoComplete="off"
width: 34, height: 34, borderRadius: "var(--radius-md)", error={
border: "1px solid var(--color-border)", background: "var(--color-surface)", errors.name?.type !== "manual" ? errors.name?.message : undefined
cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", }
display: "flex", alignItems: "center", justifyContent: "center", {...register("name")}
}}> />
×
</button> <Input
label="رقم الجوال *"
placeholder="05XXXXXXXX"
dir="ltr"
error={errors.phone?.message}
{...register("phone")}
/>
<Input
label="البريد الإلكتروني (اختياري)"
type="email"
placeholder="example@mail.com"
dir="ltr"
error={errors.email?.message}
{...register("email")}
/>
<Input
label="الجنسية (اختياري)"
placeholder="سعودي"
dir="rtl"
error={errors.nationality?.message}
{...register("nationality")}
/>
<Input
className="sm:col-span-2"
label="العنوان (اختياري)"
placeholder="الرياض، حي..."
dir="rtl"
error={errors.address?.message}
{...register("address")}
/>
</div> </div>
{/* Form */} {/* ── Section: ID & GOSI ── */}
<form <p style={sectionHeadingStyle}>الهوية والتأمينات</p>
onSubmit={handleSubmit(submitHandler)}
noValidate <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
style={{ <Select
padding: "1.5rem", label="نوع الهوية (اختياري)"
display: "flex", flexDirection: "column", gap: "1rem", dir="rtl"
maxHeight: "75vh", overflowY: "auto", error={errors.nationalIdType?.message}
}} {...register("nationalIdType")}
dir="rtl" >
> <option value="">اختر النوع</option>
{errors.name?.type === "manual" && ( <option value="NationalID">هوية وطنية</option>
<Alert type="error" message={errors.name.message ?? ""} onClose={() => setApiError("")} /> <option value="Iqama">إقامة</option>
<option value="Passport">جواز سفر</option>
</Select>
<Input
label="رقم الهوية (اختياري)"
placeholder="1XXXXXXXXX"
dir="ltr"
error={errors.nationalId?.message}
{...register("nationalId")}
/>
<Input
label="انتهاء الهوية (اختياري)"
type="date"
error={errors.nationalIdExpiry?.message}
{...register("nationalIdExpiry")}
/>
<Input
label="رقم GOSI (اختياري)"
placeholder="GOSI-XXXX"
dir="ltr"
error={errors.gosiNumber?.message}
{...register("gosiNumber")}
/>
</div>
{/* ── Section: License ── */}
<p style={sectionHeadingStyle}>رخصة القيادة</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Input
label="رقم الرخصة (اختياري)"
placeholder="LIC-XXXX"
dir="ltr"
error={errors.licenseNumber?.message}
{...register("licenseNumber")}
/>
<Input
label="نوع الرخصة (اختياري)"
placeholder="خاص / عام"
dir="rtl"
error={errors.licenseType?.message}
{...register("licenseType")}
/>
<Input
label="انتهاء الرخصة (اختياري)"
type="date"
error={errors.licenseExpiry?.message}
{...register("licenseExpiry")}
/>
</div>
{/* ── Section: Driver Card ── */}
<p style={sectionHeadingStyle}>بطاقة السائق</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Input
label="رقم البطاقة (اختياري)"
placeholder="CARD-XXXX"
dir="ltr"
error={errors.driverCardNumber?.message}
{...register("driverCardNumber")}
/>
<Select
label="نوع البطاقة (اختياري)"
dir="rtl"
error={errors.driverCardType?.message}
{...register("driverCardType")}
>
<option value="">اختر النوع</option>
<option value="Temporary">مؤقتة</option>
<option value="Seasonal">موسمية</option>
<option value="Annual">سنوية</option>
<option value="Restricted">مقيدة</option>
</Select>
<Input
label="انتهاء البطاقة (اختياري)"
type="date"
error={errors.driverCardExpiry?.message}
{...register("driverCardExpiry")}
/>
</div>
{/* ── Section: Operational ── */}
<p style={sectionHeadingStyle}>بيانات تشغيلية</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Select
label="الفرع (اختياري)"
dir="rtl"
error={errors.branchId?.message}
{...register("branchId")}
>
<option value="">اختر الفرع</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</Select>
<Input
label="نوع السائق (اختياري)"
placeholder="رئيسي / احتياطي"
dir="rtl"
error={errors.driverType?.message}
{...register("driverType")}
/>
{/* Status — edit only */}
{!isNew && (
<Select label="الحالة" dir="rtl" {...register("status")}>
<option value="Active">نشط</option>
<option value="InTrip">في رحلة</option>
<option value="Inactive">غير نشط</option>
<option value="Suspended">موقوف</option>
</Select>
)} )}
</div>
{/* ── Section: Personal Info ── */} {/* ── 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>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* Name — required */} {/* File inputs cannot use register() directly — bind via Controller
<label style={labelStyle}> to the existing FileInput component's current/onChange props. */}
الاسم الكامل * <Controller
<input name="photo"
ref={firstRef} control={control}
style={withError(!!errors.name && errors.name.type !== "manual")} render={({ field }) => (
{...register("name")} <FileInput
placeholder="محمد عبدالله" label="صورة السائق"
dir="rtl" current={field.value}
autoComplete="off" onChange={field.onChange}
/> />
{errors.name && errors.name.type !== "manual" && (
<span style={errorTextStyle}>{errors.name.message}</span>
)}
</label>
{/* Phone — required */}
<label style={labelStyle}>
رقم الجوال *
<input
style={withError(!!errors.phone)}
{...register("phone")}
placeholder="05XXXXXXXX"
dir="ltr"
/>
{errors.phone && <span style={errorTextStyle}>{errors.phone.message}</span>}
</label>
{/* Email — optional */}
<label style={labelStyle}>
البريد الإلكتروني
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={withError(!!errors.email)}
type="email"
{...register("email")}
placeholder="example@mail.com"
dir="ltr"
/>
{errors.email && <span style={errorTextStyle}>{errors.email.message}</span>}
</label>
{/* Nationality — optional */}
<label style={labelStyle}>
الجنسية
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={withError(!!errors.nationality)}
{...register("nationality")}
placeholder="سعودي"
dir="rtl"
/>
{errors.nationality && <span style={errorTextStyle}>{errors.nationality.message}</span>}
</label>
{/* Address — optional, full width */}
<label style={{ ...labelStyle, gridColumn: "1 / -1" }}>
العنوان
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={withError(!!errors.address)}
{...register("address")}
placeholder="الرياض، حي..."
dir="rtl"
/>
{errors.address && <span style={errorTextStyle}>{errors.address.message}</span>}
</label>
</div>
{/* ── Section: ID & GOSI ── */}
<p style={sectionHeadingStyle}>الهوية والتأمينات</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
{/* National ID Type — optional */}
<label style={labelStyle}>
نوع الهوية
<span style={optionalLabelStyle}>(اختياري)</span>
<select
style={{ ...withError(!!errors.nationalIdType), cursor: "pointer" }}
{...register("nationalIdType")}
dir="rtl"
>
<option value="">اختر النوع</option>
<option value="NationalID">هوية وطنية</option>
<option value="Iqama">إقامة</option>
<option value="Passport">جواز سفر</option>
</select>
{errors.nationalIdType && <span style={errorTextStyle}>{errors.nationalIdType.message}</span>}
</label>
{/* National ID number */}
<label style={labelStyle}>
رقم الهوية
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("nationalId")} placeholder="1XXXXXXXXX" dir="ltr" />
</label>
{/* National ID expiry */}
<label style={labelStyle}>
انتهاء الهوية
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} type="date" {...register("nationalIdExpiry")} />
</label>
{/* GOSI — optional */}
<label style={labelStyle}>
رقم GOSI
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("gosiNumber")} placeholder="GOSI-XXXX" dir="ltr" />
</label>
</div>
{/* ── Section: License ── */}
<p style={sectionHeadingStyle}>رخصة القيادة</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
{/* License number — optional */}
<label style={labelStyle}>
رقم الرخصة
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={withError(!!errors.licenseNumber)}
{...register("licenseNumber")}
placeholder="LIC-XXXX"
dir="ltr"
/>
{errors.licenseNumber && <span style={errorTextStyle}>{errors.licenseNumber.message}</span>}
</label>
{/* License type — optional */}
<label style={labelStyle}>
نوع الرخصة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("licenseType")} placeholder="خاص / عام" dir="rtl" />
</label>
{/* License expiry — optional, validated if provided */}
<label style={labelStyle}>
انتهاء الرخصة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={withError(!!errors.licenseExpiry)} type="date" {...register("licenseExpiry")} />
{errors.licenseExpiry && <span style={errorTextStyle}>{errors.licenseExpiry.message}</span>}
</label>
</div>
{/* ── Section: Driver Card ── */}
<p style={sectionHeadingStyle}>بطاقة السائق</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
{/* Card number — optional */}
<label style={labelStyle}>
رقم البطاقة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("driverCardNumber")} placeholder="CARD-XXXX" dir="ltr" />
</label>
{/* Card type — optional */}
<label style={labelStyle}>
نوع البطاقة
<span style={optionalLabelStyle}>(اختياري)</span>
<select
style={{ ...withError(!!errors.driverCardType), cursor: "pointer" }}
{...register("driverCardType")}
dir="rtl"
>
<option value="">اختر النوع</option>
<option value="Temporary">مؤقتة</option>
<option value="Seasonal">موسمية</option>
<option value="Annual">سنوية</option>
<option value="Restricted">مقيدة</option>
</select>
{errors.driverCardType && <span style={errorTextStyle}>{errors.driverCardType.message}</span>}
</label>
{/* Card expiry — optional, validated if provided */}
<label style={labelStyle}>
انتهاء البطاقة
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={withError(!!errors.driverCardExpiry)}
type="date"
{...register("driverCardExpiry")}
/>
{errors.driverCardExpiry && (
<span style={errorTextStyle}>{errors.driverCardExpiry.message}</span>
)}
</label>
</div>
{/* ── Section: Operational ── */}
<p style={sectionHeadingStyle}>بيانات تشغيلية</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
{/* Branch — optional */}
<label style={labelStyle}>
الفرع
<span style={optionalLabelStyle}>(اختياري)</span>
<select
style={{ ...withError(!!errors.branchId), cursor: "pointer" }}
{...register("branchId")}
dir="rtl"
>
<option value="">اختر الفرع</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
{errors.branchId && <span style={errorTextStyle}>{errors.branchId.message}</span>}
</label>
{/* Driver type — optional */}
<label style={labelStyle}>
نوع السائق
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("driverType")} placeholder="رئيسي / احتياطي" dir="rtl" />
</label>
{/* Status — edit only */}
{!isNew && (
<label style={labelStyle}>
الحالة
<select style={{ ...inputBase, cursor: "pointer" }} {...register("status")} dir="rtl">
<option value="Active">نشط</option>
<option value="InTrip">في رحلة</option>
<option value="Inactive">غير نشط</option>
<option value="Suspended">موقوف</option>
</select>
</label>
)} )}
</div> />
<Controller
{/* ── Section: Photos ── */} name="nationalPhoto"
<p style={sectionHeadingStyle}>الصور والمستندات</p> control={control}
<p style={{ fontSize: 11, color: "var(--color-text-hint)", margin: "0.25rem 0 0", fontWeight: 400 }}> render={({ field }) => (
جميع حقول الصور اختيارية <FileInput
</p> label="صورة الهوية"
current={field.value}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}> onChange={field.onChange}
{/* File inputs cannot use register() directly — bind via Controller />
to the existing FileInput component's current/onChange props. */} )}
<Controller />
name="photo" <Controller
control={control} name="driverCardPhoto"
render={({ field }) => ( control={control}
<FileInput label="صورة السائق" current={field.value} onChange={field.onChange} /> render={({ field }) => (
)} <FileInput
/> label="صورة البطاقة"
<Controller current={field.value}
name="nationalPhoto" onChange={field.onChange}
control={control} />
render={({ field }) => ( )}
<FileInput label="صورة الهوية" current={field.value} onChange={field.onChange} /> />
)} </div>
/> </form>
<Controller </Modal>
name="driverCardPhoto"
control={control}
render={({ field }) => (
<FileInput label="صورة البطاقة" current={field.value} onChange={field.onChange} />
)}
/>
</div>
{/* ── Actions ── */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
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: isSubmitting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء
</button>
<button
type="submit"
disabled={isSubmitting}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "none",
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: isSubmitting ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{isSubmitting && <Spinner size="sm" className="text-white" />}
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة السائق" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
); );
} }

View File

@@ -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 && (

View 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)}
/>
)}
/>
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Spinner } from "../../UI"; import { Button, Modal, Spinner, Toast } from "../../UI";
import { PhotoCard } from "../DriverPhotos"; import { PhotoCard } from "../DriverPhotos";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { archivedDriverService } from "@/src/services/archive/archivedDriver.service"; import { archivedDriverService } from "@/src/services/archive/archivedDriver.service";
@@ -11,6 +11,7 @@ import {
NATIONAL_ID_TYPE_MAP, NATIONAL_ID_TYPE_MAP,
} from "@/src/types/driver"; } from "@/src/types/driver";
import type { ArchivedDriver, DriverStatusHistoryEntry } from "@/src/types/driver"; import type { ArchivedDriver, DriverStatusHistoryEntry } from "@/src/types/driver";
import type { ToastNotification } from "@/src/Components/UI";
interface ArchivedDriverDetailModalProps { interface ArchivedDriverDetailModalProps {
driverId: string; driverId: string;
@@ -18,6 +19,7 @@ interface ArchivedDriverDetailModalProps {
} }
// ── small helper components ─────────────────────────────────────────────────── // ── small helper components ───────────────────────────────────────────────────
// (No shared-UI equivalents — purpose-built layouts, not generic controls.)
/** Renders a single label/value row inside the detail body. */ /** Renders a single label/value row inside the detail body. */
function DetailRow({ label, value }: { label: string; value?: string | null }) { function DetailRow({ label, value }: { label: string; value?: string | null }) {
@@ -55,44 +57,35 @@ function Avatar({ name }: { name: string }) {
/** /**
* Modal showing the full profile of a single archived driver, including * Modal showing the full profile of a single archived driver, including
* documents/photos and status history. Fetches `getById` and * documents/photos and status history. Fetches `getById` on mount — the
* `getStatusHistory` in parallel on mount. * archived detail response includes status history embedded.
*/ */
export function ArchivedDriverDetailModal({ driverId, onClose }: ArchivedDriverDetailModalProps) { export function ArchivedDriverDetailModal({ driverId, onClose }: ArchivedDriverDetailModalProps) {
const [driver, setDriver] = useState<ArchivedDriver | null>(null); const [driver, setDriver] = useState<ArchivedDriver | null>(null);
const [history, setHistory] = useState<DriverStatusHistoryEntry[]>([]); const [history, setHistory] = useState<DriverStatusHistoryEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [notification, setNotification] = useState<ToastNotification | null>(null);
// close on Escape // fetch archived driver details on mount — status history comes embedded
// in the same response, no separate call needed
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; let cancelled = false;
window.addEventListener("keydown", handler); (async () => {
return () => window.removeEventListener("keydown", handler); try {
}, [onClose]); const token = getStoredToken();
const driverData = await archivedDriverService.getByIdUnwrapped(driverId, token);
// fetch archived driver details + status history on mount if (!cancelled) {
useEffect(() => { setDriver(driverData);
let cancelled = false; setHistory(driverData.statusHistory ?? []);
(async () => { }
try { } catch {
const token = getStoredToken(); if (!cancelled) setNotification({ type: "error", message: "تعذّر تحميل بيانات السائق المؤرشف. يرجى المحاولة لاحقاً." });
const [driverData, historyList] = await Promise.all([ } finally {
archivedDriverService.getByIdUnwrapped(driverId, token), if (!cancelled) setLoading(false);
archivedDriverService.getStatusHistoryUnwrapped(driverId, token),
]);
if (!cancelled) {
setDriver(driverData);
setHistory(historyList ?? []);
} }
} catch { })();
if (!cancelled) setError("تعذّر تحميل بيانات السائق المؤرشف. يرجى المحاولة لاحقاً."); return () => { cancelled = true; };
} finally { }, [driverId]);
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [driverId]);
// ── helpers ─────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────
const fmt = (iso?: string | null) => const fmt = (iso?: string | null) =>
@@ -102,200 +95,124 @@ export function ArchivedDriverDetailModal({ driverId, onClose }: ArchivedDriverD
// ── render ──────────────────────────────────────────────────────────────── // ── render ────────────────────────────────────────────────────────────────
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="archived-driver-detail-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={driver?.name ?? "عرض السائق"}
style={{ subtitle="سائق مؤرشف"
position: "fixed", inset: 0, zIndex: 55, onClose={onClose}
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", zIndex={60}
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", size="lg"
}} footer={
<Button type="button" variant="secondary" onClick={onClose}>
إغلاق
</Button>
}
> >
<div {/* loading */}
onClick={e => e.stopPropagation()} {loading && (
style={{ <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
width: "100%", maxWidth: 560, <Spinner size="sm" className="text-blue-600" />
background: "var(--color-surface)", <span style={{ fontSize: 13 }}>جارٍ التحميل</span>
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
}}
>
{/* ── 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: "#EA580C", fontWeight: 600, margin: 0 }}>
سائق مؤرشف
</p>
<h2 id="archived-driver-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{driver?.name ?? "عرض السائق"}
</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> </div>
)}
{/* ── body ── */} {/* error */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "75vh" }}> {!loading && <Toast notification={notification} onDismiss={() => setNotification(null)} />}
{/* loading */} {/* content */}
{loading && ( {!loading && driver && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}> <div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error */} {/* avatar + name + status row */}
{!loading && error && ( <div style={{
<div style={{ display: "flex", alignItems: "center", gap: "1rem",
padding: "1rem 1.25rem", padding: "0 0 1.25rem",
borderRadius: "var(--radius-lg)", borderBottom: "1px solid var(--color-border)",
background: "#FEF2F2", border: "1px solid #FECACA", marginBottom: "0.25rem",
fontSize: 13, color: "#991B1B", fontWeight: 500, }}>
textAlign: "center", <Avatar name={driver.name} />
}}> <div style={{ flex: 1, minWidth: 0 }}>
{error} <p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{driver.name}</p>
</div> {driver.userName && (
)} <p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
@{driver.userName}
{/* content */} </p>
{!loading && driver && ( )}
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl"> {statusConfig && (
<span style={{
{/* avatar + name + status row */} marginTop: 8, display: "inline-flex", alignItems: "center", gap: 6,
<div style={{ borderRadius: "var(--radius-full)",
display: "flex", alignItems: "center", gap: "1rem", border: `1px solid ${statusConfig.border}`,
padding: "0 0 1.25rem", background: statusConfig.bg,
borderBottom: "1px solid var(--color-border)", padding: "0.25rem 0.75rem",
marginBottom: "0.25rem", fontSize: 12, fontWeight: 600, color: statusConfig.color,
}}> }}>
<Avatar name={driver.name} /> <span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot }} />
<div style={{ flex: 1, minWidth: 0 }}> {statusConfig.label}
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{driver.name}</p> </span>
{driver.userName && (
<p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
@{driver.userName}
</p>
)}
{statusConfig && (
<span style={{
marginTop: 8, display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: `1px solid ${statusConfig.border}`,
background: statusConfig.bg,
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600, color: statusConfig.color,
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot }} />
{statusConfig.label}
</span>
)}
</div>
</div>
{/* core info */}
<DetailRow label="رقم الجوال" value={driver.phone} />
<DetailRow label="البريد الإلكتروني" value={driver.email} />
<DetailRow label="العنوان" value={driver.address} />
<DetailRow label="الجنسية" value={driver.nationality} />
<DetailRow label="نوع الهوية" value={driver.nationalIdType ? NATIONAL_ID_TYPE_MAP[driver.nationalIdType] : null} />
<DetailRow label="رقم الهوية" value={driver.nationalId} />
<DetailRow label="رقم GOSI" value={driver.gosiNumber} />
<DetailRow label="رقم الرخصة" value={driver.licenseNumber} />
<DetailRow label="نوع الرخصة" value={driver.licenseType} />
<DetailRow label="انتهاء الرخصة" value={fmt(driver.licenseExpiry)} />
<DetailRow label="رقم بطاقة السائق" value={driver.driverCardNumber} />
<DetailRow label="نوع بطاقة السائق" value={driver.driverCardType ? DRIVER_CARD_TYPE_MAP[driver.driverCardType] : null} />
<DetailRow label="نوع السائق" value={driver.driverType} />
<DetailRow label="تاريخ الإنشاء" value={fmt(driver.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(driver.updatedAt)} />
{/* photos */}
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.5rem" }}>
الصور والمستندات
</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<PhotoCard url={driver.photoUrl} label="صورة السائق" />
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
<PhotoCard url={driver.driverCardPhotoUrl} label="صورة البطاقة" />
</div>
{/* status history */}
{history.length > 0 && (
<>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.5rem" }}>
سجل الحالات
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{history.map((h) => {
const s = DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active;
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)" }}>{fmt(h.createdAt)}</span>
</div>
);
})}
</div>
</>
)} )}
</div> </div>
</div>
{/* core info */}
<DetailRow label="رقم الجوال" value={driver.phone} />
<DetailRow label="البريد الإلكتروني" value={driver.email} />
<DetailRow label="العنوان" value={driver.address} />
<DetailRow label="الجنسية" value={driver.nationality} />
<DetailRow label="نوع الهوية" value={driver.nationalIdType ? NATIONAL_ID_TYPE_MAP[driver.nationalIdType] : null} />
<DetailRow label="رقم الهوية" value={driver.nationalId} />
<DetailRow label="رقم GOSI" value={driver.gosiNumber} />
<DetailRow label="رقم الرخصة" value={driver.licenseNumber} />
<DetailRow label="نوع الرخصة" value={driver.licenseType} />
<DetailRow label="انتهاء الرخصة" value={fmt(driver.licenseExpiry)} />
<DetailRow label="رقم بطاقة السائق" value={driver.driverCardNumber} />
<DetailRow label="نوع بطاقة السائق" value={driver.driverCardType ? DRIVER_CARD_TYPE_MAP[driver.driverCardType] : null} />
<DetailRow label="نوع السائق" value={driver.driverType} />
<DetailRow label="تاريخ الإنشاء" value={fmt(driver.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(driver.updatedAt)} />
{/* photos */}
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.5rem" }}>
الصور والمستندات
</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<PhotoCard url={driver.photoUrl} label="صورة السائق" />
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
<PhotoCard url={driver.driverCardPhotoUrl} label="صورة البطاقة" />
</div>
{/* status history */}
{history.length > 0 && (
<>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.5rem" }}>
سجل الحالات
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{history.map((h) => {
const s = DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active;
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)" }}>{fmt(h.createdAt)}</span>
</div>
);
})}
</div>
</>
)} )}
</div> </div>
)}
{/* ── footer ── */} </Modal>
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", justifyContent: "flex-end",
}}>
<button
type="button" onClick={onClose}
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>
</div>
</div>
); );
} }

View File

@@ -1,141 +1,86 @@
"use client"; "use client";
import { 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 ─────────────────────────────────────────────────────────────
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>
);
}
// ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── 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 */ },
<div style={{ textAlign: "center", padding: "4rem 1rem" }}> {
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}> key: "userName",
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد سائقون في الأرشيف."} header: "اسم السائق",
</p> width: "1.5fr",
</div> render: (d) => (
) : ( <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{d.userName ?? "—"}</span>
/* data rows */ ),
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> },
{drivers.map((d, i) => ( {
<li key={d.id} onClick={() => onView(d)} style={{ key: "status",
display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, header: "الحالة",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem", width: "1fr",
borderBottom: "1px solid var(--color-border)", render: (d) => {
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", const s = DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive;
fontSize: 13, cursor: "pointer", 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 }}>
<div> <span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p> {s.label}
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
</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" }}> );
{[ },
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, {
].map(btn => ( key: "createdAt",
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled} header: "تاريخ الانشاء",
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)" }}> width: "1fr",
{btn.label} align: "center",
</button> render: (d) => new Date(d.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
))} },
</div> {
</div> key: "updatedAt",
)} header: "تاريخ التعديل",
</div> width: "1fr",
align: "center",
render: (d) => new Date(d.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
},
];
return (
<ReusableTable
columns={columns}
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)} />}
/>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "../../UI"; import { Input, Modal, Toast } from "../../UI";
import { ArchivedDriverTable } from "./ArchivedDriverTable"; import { ArchivedDriverTable } from "./ArchivedDriverTable";
import { ArchivedDriverDetailModal } from "./ArchivedDriverDetailModal"; import { ArchivedDriverDetailModal } from "./ArchivedDriverDetailModal";
import { useArchivedDrivers } from "@/src/hooks/archive/useArchivedDrivers"; import { useArchivedDrivers } from "@/src/hooks/archive/useArchivedDrivers";
@@ -10,6 +10,10 @@ interface ArchivedDriversProps {
onClose: () => void; onClose: () => void;
} }
const searchIcon = (
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
);
/** /**
* Top-level modal for browsing archived drivers. Wires the * Top-level modal for browsing archived drivers. Wires the
* useArchivedDrivers hook to the table and detail modal, mirroring * useArchivedDrivers hook to the table and detail modal, mirroring
@@ -19,89 +23,38 @@ export function ArchivedDrivers({ onClose }: ArchivedDriversProps) {
const [viewDriverId, setViewDriverId] = useState<string | null>(null); const [viewDriverId, setViewDriverId] = useState<string | null>(null);
const { const {
drivers, loading, total, pages, error, drivers, loading, total, pages, notification,
page, search, page, search,
setPage, handleSearch, clearError, setPage, handleSearch, clearNotification,
} = useArchivedDrivers(); } = useArchivedDrivers();
return ( return (
<div <>
role="dialog" aria-modal="true" aria-labelledby="archive-driver-modal-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 70,
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "flex-start", justifyContent: "center",
padding: "2rem 1rem", overflowY: "auto",
}}
>
{viewDriverId && ( {viewDriverId && (
<ArchivedDriverDetailModal driverId={viewDriverId} onClose={() => setViewDriverId(null)} /> <ArchivedDriverDetailModal driverId={viewDriverId} onClose={() => setViewDriverId(null)} />
)} )}
<div <Modal
onClick={e => e.stopPropagation()} open
style={{ onClose={onClose}
width: "100%", maxWidth: 920, size="lg"
background: "var(--color-surface-sunken)", subtitle="الأرشيف"
borderRadius: "var(--radius-xl)", title={`السائقون المؤرشفون (${total})`}
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
overflow: "hidden",
}}
> >
{/* header */} <div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-driver-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
السائقون المؤرشفون
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</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 */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */} {/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}> <div style={{ maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }} <Input
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> label="بحث"
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالاسم أو الهاتف..." placeholder="بحث بالاسم أو الهاتف..."
value={search} value={search}
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
icon={searchIcon}
dir="rtl" 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, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/> />
</div> </div>
{error && <Alert type="error" message={error} onClose={clearError} />} <Toast notification={notification} onDismiss={clearNotification} />
<ArchivedDriverTable <ArchivedDriverTable
drivers={drivers} drivers={drivers}
@@ -113,7 +66,7 @@ export function ArchivedDrivers({ onClose }: ArchivedDriversProps) {
onPageChange={setPage} onPageChange={setPage}
/> />
</div> </div>
</div> </Modal>
</div> </>
); );
} }

View File

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Spinner } from "../UI"; import { Alert, Button, Input } from "../UI";
import { driverService } from "@/src/services/driver.service"; import { driverService } from "@/src/services/driver.service";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
@@ -65,20 +65,8 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
}} }}
> >
<div style={{ flex: 1, minWidth: 140 }}> <div style={{ flex: 1, minWidth: 140 }}>
<label <Input
htmlFor="report-date" label="تاريخ التقرير"
style={{
display: "block",
fontSize: 11,
fontWeight: 600,
color: "var(--color-text-muted)",
marginBottom: 4,
}}
>
تاريخ التقرير
</label>
<input
id="report-date"
type="date" type="date"
value={date} value={date}
max={today} max={today}
@@ -86,64 +74,21 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
setDate(e.target.value); setDate(e.target.value);
setError(null); setError(null);
}} }}
style={{
width: "100%",
height: 38,
padding: "0 0.625rem",
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)",
}}
/> />
</div> </div>
<button <Button onClick={handleGenerate} disabled={!date} loading={loading}>
type="button"
onClick={handleGenerate}
disabled={loading || !date}
style={{
height: 38,
padding: "0 1rem",
borderRadius: "var(--radius-md)",
border: "none",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
background: !date || loading
? "var(--color-brand-400)"
: "var(--color-brand-600)",
cursor: !date || loading ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
whiteSpace: "nowrap",
}}
>
{loading && <Spinner size="sm" className="text-white" />}
{loading ? "جارٍ الإنشاء…" : "إنشاء التقرير"} {loading ? "جارٍ الإنشاء…" : "إنشاء التقرير"}
</button> </Button>
</div> </div>
{error && ( {error && (
<div <Alert
style={{ type="error"
marginTop: "0.75rem", message={error}
borderRadius: "var(--radius-md)", onClose={() => setError(null)}
background: "#FEF2F2", className="mt-3"
border: "1px solid #FECACA", />
padding: "0.625rem 0.875rem",
fontSize: 12,
color: "#DC2626",
fontWeight: 500,
}}
>
{error}
</div>
)} )}
</div> </div>
); );

View File

View 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>
);
}

View File

@@ -0,0 +1,474 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Alert, Button, Input, Select, Spinner } 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",
}}
>
<i className="ti ti-package" style={{ fontSize: 24, color: "var(--color-brand-600)" }} aria-hidden="true" />
</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 && <Alert type="error" message={error} />}
{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"
>
{Object.entries(ORDER_STATUS_MAP).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</Select>
<Input
label="سبب التغيير"
type="text"
value={statusReason}
onChange={(e) => setStatusReason(e.target.value)}
placeholder="سبب التغيير (اختياري)"
dir="rtl"
/>
<Button
type="button"
onClick={handleStatusUpdate}
disabled={!statusDraft || statusDraft === order.currentStatus}
loading={updatingStatus}
>
{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" variant="danger" onClick={() => onDelete(order)}>
حذف
</Button>
<Button type="button" variant="secondary" onClick={() => onEdit(order)}>
<i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
تعديل
</Button>
<Button type="button" variant="secondary" fullWidth onClick={onClose}>
إغلاق
</Button>
</div>
)}
</aside>
</>
);
}
export { ORDER_STATUS_MAP, PAY_STATUS_MAP, PAY_METHOD_LABEL };

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useForm } from "react-hook-form"; import { useForm, type Resolver } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Spinner } from "../UI"; import { Alert, Button, Input, Modal, Select } from "../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { clientService } from "@/src/services/client.service"; import { clientService } from "@/src/services/client.service";
import { tripService } from "@/src/services/trip.service"; import { tripService } from "@/src/services/trip.service";
@@ -22,34 +22,6 @@ import type {
import type { Client } from "@/src/types/client"; import type { Client } from "@/src/types/client";
import type { Trip } from "@/src/types/trip"; import type { Trip } from "@/src/types/trip";
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)",
};
const errorTextStyle: React.CSSProperties = {
fontSize: 11,
color: "var(--color-danger)",
fontWeight: 500,
};
const sectionHeadingStyle: React.CSSProperties = { const sectionHeadingStyle: React.CSSProperties = {
fontSize: 11, fontSize: 11,
letterSpacing: "0.25em", letterSpacing: "0.25em",
@@ -59,19 +31,12 @@ const sectionHeadingStyle: React.CSSProperties = {
margin: "0.5rem 0 0", margin: "0.5rem 0 0",
}; };
const optionalLabelStyle: React.CSSProperties = {
fontSize: 10,
fontWeight: 500,
color: "var(--color-text-hint)",
marginRight: 4,
};
const createLinkStyle: React.CSSProperties = { const createLinkStyle: React.CSSProperties = {
fontSize: 11, fontSize: 11,
color: "var(--color-brand-600)", color: "var(--color-brand-600)",
textDecoration: "none", textDecoration: "none",
fontWeight: 500, fontWeight: 500,
marginTop: 2, marginTop: 6,
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: 3, gap: 3,
@@ -147,9 +112,23 @@ export function OrderFormModal({
register, register,
handleSubmit, handleSubmit,
setError, setError,
setFocus,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<OrderFormValues>({ } = useForm<OrderFormValues>({
resolver: yupResolver(isNew ? createOrderSchema : updateOrderSchema) as never, // create/update schemas differ structurally in which fields are
// required (e.g. tripId), so yup can't unify them into one type on
// its own — the ternary alone won't typecheck as a single schema
// argument. We cast in two places for two separate reasons:
// 1. `(...) as any` on the schema — lets the ternary's two
// differently-shaped ObjectSchemas pass as a single argument.
// 2. `as unknown as Resolver<OrderFormValues>` on the whole call —
// stops TS from inferring the Resolver's generics from the
// schema's own (slightly different) inferred shape, which is
// what caused the earlier "quantity optional vs required"
// mismatch.
resolver: yupResolver(
(isNew ? createOrderSchema : updateOrderSchema) as any,
) as unknown as Resolver<OrderFormValues>,
defaultValues: { defaultValues: {
shipmentNumber: editOrder?.shipmentNumber ?? "", shipmentNumber: editOrder?.shipmentNumber ?? "",
recipientName: editOrder?.recipientName ?? "", recipientName: editOrder?.recipientName ?? "",
@@ -200,7 +179,12 @@ export function OrderFormModal({
}); });
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");
const firstRef = useRef<HTMLInputElement>(null);
// register("shipmentNumber") already attaches its own ref — focusing via
// setFocus avoids the ref collision a separate `ref={firstRef}` would cause.
useEffect(() => {
setFocus("shipmentNumber");
}, [setFocus]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -227,22 +211,6 @@ export function OrderFormModal({
}; };
}, []); }, []);
useEffect(() => {
firstRef.current?.focus();
}, []);
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
const withError = (hasError: boolean): React.CSSProperties => ({
...inputBase,
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
const numberField = ( const numberField = (
field: field:
| "quantity" | "quantity"
@@ -340,438 +308,365 @@ export function OrderFormModal({
const pickupErr = errors.pickupAddress; const pickupErr = errors.pickupAddress;
return ( return (
<div <Modal
role="dialog" open
aria-modal="true" title={isNew ? "طلب جديد" : editOrder?.shipmentNumber ?? ""}
aria-labelledby="order-modal-title" subtitle={isNew ? "إنشاء طلب" : "تعديل طلب"}
onClick={(e) => { onClose={onClose}
if (e.target === e.currentTarget) onClose(); size="lg"
}} footer={
style={{ <>
position: "fixed", <Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
inset: 0, إلغاء
zIndex: 50, </Button>
background: "rgba(15,23,42,0.55)", <Button type="submit" form="order-form" loading={isSubmitting}>
backdropFilter: "blur(4px)", {isNew ? "إنشاء الطلب" : "حفظ التغييرات"}
display: "flex", </Button>
alignItems: "center", </>
justifyContent: "center", }
padding: "1rem",
overflowY: "auto",
}}
> >
<div <form
onClick={(e) => e.stopPropagation()} id="order-form"
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", noValidate
maxWidth: 640, style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
background: "var(--color-surface)", dir="rtl"
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
margin: "auto",
}}
> >
<div {errors.shipmentNumber?.type === "manual" && (
style={{ <Alert type="error" message={errors.shipmentNumber.message ?? ""} onClose={() => setApiError("")} />
display: "flex", )}
alignItems: "center",
justifyContent: "space-between", <p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p>
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)", <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
background: "var(--color-surface-muted)", <Input
}} id="order-shipmentNumber"
> label="رقم الشحنة *"
error={
errors.shipmentNumber && errors.shipmentNumber.type !== "manual"
? errors.shipmentNumber.message
: undefined
}
{...register("shipmentNumber")}
placeholder="SHP-0001"
dir="ltr"
autoComplete="off"
/>
<div> <div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> <Select
{isNew ? "إنشاء طلب" : "تعديل طلب"} id="order-clientId"
</p> label="العميل *"
<h2 error={errors.clientId?.message}
id="order-modal-title" disabled={relLoading}
style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0", fontFamily: "var(--font-mono)" }} {...register("clientId")}
dir="rtl"
style={{ cursor: relLoading ? "wait" : "pointer" }}
> >
{isNew ? "طلب جديد" : editOrder?.shipmentNumber} <option value="">{relLoading ? "جارٍ التحميل…" : "اختر العميل"}</option>
</h2> {clients.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{c.phone ? `${c.phone}` : ""}
</option>
))}
</Select>
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
<i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
إضافة عميل جديد
</Link>
</div>
<Input
id="order-recipientName"
label="اسم المستلم *"
error={errors.recipientName?.message}
{...register("recipientName")}
placeholder="أحمد محمد"
dir="rtl"
/>
<Input
id="order-recipientPhone"
label="رقم جوال المستلم *"
error={errors.recipientPhone?.message}
{...register("recipientPhone")}
placeholder="05XXXXXXXX"
dir="ltr"
/>
<div style={{ gridColumn: "1 / -1" }}>
<Select
id="order-tripId"
label={isNew ? "الرحلة *" : "الرحلة (اختياري)"}
error={errors.tripId?.message}
disabled={relLoading}
{...register("tripId")}
dir="rtl"
style={{ cursor: relLoading ? "wait" : "pointer" }}
>
<option value="">{relLoading ? "جارٍ التحميل…" : "اختر الرحلة"}</option>
{trips.map((t) => (
<option key={t.id} value={t.id}>
{t.tripNumber}
{t.title ? `${t.title}` : ""}
{t.driver?.name ? ` (${t.driver.name})` : ""}
</option>
))}
</Select>
<Link href="/dashboard/trips" style={createLinkStyle} tabIndex={-1}>
<i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
إنشاء رحلة جديدة
</Link>
</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",
}}
>
×
</button>
</div> </div>
<form <p style={sectionHeadingStyle}>تفاصيل الشحنة</p>
onSubmit={handleSubmit(submitHandler)}
noValidate <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", maxHeight: "75vh", overflowY: "auto" }} <Input
dir="rtl" id="order-type"
> label="نوع الشحنة (اختياري)"
{errors.shipmentNumber?.type === "manual" && ( {...register("type")}
<Alert type="error" message={errors.shipmentNumber.message ?? ""} onClose={() => setApiError("")} /> placeholder="عادي / مبرد"
dir="rtl"
/>
<Input
id="order-quantity"
label="الكمية *"
type="number"
min={1}
error={errors.quantity?.message}
{...numberField("quantity")}
dir="ltr"
/>
<Input
id="order-weight"
label="الوزن (كجم) (اختياري)"
type="number"
min={0}
step="0.01"
{...numberField("weight")}
dir="ltr"
/>
</div>
<p style={sectionHeadingStyle}>بيانات الدفع</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<Input
id="order-subTotal"
label="الإجمالي الفرعي (اختياري)"
type="number"
min={0}
step="0.01"
error={errors.subTotal?.message}
{...numberField("subTotal")}
dir="ltr"
/>
<Input
id="order-vatRate"
label="نسبة الضريبة (%) (اختياري)"
type="number"
min={0}
step="0.01"
error={errors.vatRate?.message}
{...numberField("vatRate")}
dir="ltr"
/>
<Select
id="order-paymentMethod"
label="طريقة الدفع (اختياري)"
{...register("paymentMethod")}
dir="rtl"
style={{ cursor: "pointer" }}
>
<option value="">اختر الطريقة</option>
<option value="Cash">نقداً</option>
<option value="Card">بطاقة</option>
<option value="Prepaid">مدفوع مسبقاً</option>
</Select>
{!isNew && (
<Select
id="order-paymentStatus"
label="حالة الدفع"
{...register("paymentStatus")}
dir="rtl"
style={{ cursor: "pointer" }}
>
<option value=""></option>
<option value="Pending">معلَّق</option>
<option value="Paid">مدفوع</option>
<option value="Failed">فشل</option>
<option value="Refunded">مُسترجع</option>
</Select>
)} )}
</div>
<p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p> <p style={sectionHeadingStyle}>عنوان التسليم</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<Input
id="delivery-city"
label="المدينة (اختياري)"
{...register("deliveryAddress.details.city")}
placeholder="الرياض"
dir="rtl"
/>
<Input
id="delivery-district"
label="الحي (اختياري)"
{...register("deliveryAddress.details.district")}
placeholder="العليا"
dir="rtl"
/>
<Input
id="delivery-street"
label="الشارع (اختياري)"
{...register("deliveryAddress.details.street")}
placeholder="شارع الأمير محمد"
dir="rtl"
/>
<Input
id="delivery-buildingNo"
label="رقم المبنى (اختياري)"
{...register("deliveryAddress.details.buildingNo")}
placeholder="1234"
dir="ltr"
/>
<Input
id="delivery-unitNo"
label="رقم الوحدة (اختياري)"
{...register("deliveryAddress.details.unitNo")}
placeholder="56"
dir="ltr"
/>
<Input
id="delivery-zipCode"
label="الرمز البريدي (اختياري)"
{...register("deliveryAddress.details.zipCode")}
placeholder="12345"
dir="ltr"
/>
<Input
id="delivery-lng"
label="خط الطول (Longitude) *"
type="number"
step="any"
error={deliveryErr?.location?.coordinates ? " " : undefined}
{...coordField("deliveryAddress.location.coordinates.0")}
placeholder="46.6753"
dir="ltr"
/>
<Input
id="delivery-lat"
label="خط العرض (Latitude) *"
type="number"
step="any"
error={deliveryErr?.location?.coordinates?.message}
{...coordField("deliveryAddress.location.coordinates.1")}
placeholder="24.7136"
dir="ltr"
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <p style={sectionHeadingStyle}>عنوان الاستلام</p>
<label style={labelStyle}>
رقم الشحنة *
<input
ref={firstRef}
style={withError(!!errors.shipmentNumber && errors.shipmentNumber.type !== "manual")}
{...register("shipmentNumber")}
placeholder="SHP-0001"
dir="ltr"
autoComplete="off"
/>
{errors.shipmentNumber && errors.shipmentNumber.type !== "manual" && (
<span style={errorTextStyle}>{errors.shipmentNumber.message}</span>
)}
</label>
<label style={labelStyle}> <div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.25rem" }}>
العميل * {(["id", "new"] as const).map((m) => (
<select <Button
style={{ ...withError(!!errors.clientId), cursor: relLoading ? "wait" : "pointer" }} key={m}
disabled={relLoading}
{...register("clientId")}
dir="rtl"
>
<option value="">{relLoading ? "جارٍ التحميل…" : "اختر العميل"}</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{c.phone ? `${c.phone}` : ""}
</option>
))}
</select>
{errors.clientId && <span style={errorTextStyle}>{errors.clientId.message}</span>}
<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">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إنشاء عميل جديد
</Link>
</label>
<label style={labelStyle}>
اسم المستلم *
<input style={withError(!!errors.recipientName)} {...register("recipientName")} placeholder="أحمد محمد" dir="rtl" />
{errors.recipientName && <span style={errorTextStyle}>{errors.recipientName.message}</span>}
</label>
<label style={labelStyle}>
رقم جوال المستلم *
<input style={withError(!!errors.recipientPhone)} {...register("recipientPhone")} placeholder="05XXXXXXXX" dir="ltr" />
{errors.recipientPhone && <span style={errorTextStyle}>{errors.recipientPhone.message}</span>}
</label>
<label style={{ ...labelStyle, gridColumn: "1 / -1" }}>
{isNew ? "الرحلة *" : "الرحلة"}
{!isNew && <span style={optionalLabelStyle}>(اختياري)</span>}
<select
style={{ ...withError(!!errors.tripId), cursor: relLoading ? "wait" : "pointer" }}
disabled={relLoading}
{...register("tripId")}
dir="rtl"
>
<option value="">{relLoading ? "جارٍ التحميل…" : "اختر الرحلة"}</option>
{trips.map((t) => (
<option key={t.id} value={t.id}>
{t.tripNumber}
{t.title ? `${t.title}` : ""}
{t.driver?.name ? ` (${t.driver.name})` : ""}
</option>
))}
</select>
{errors.tripId && <span style={errorTextStyle}>{errors.tripId.message}</span>}
<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">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إنشاء رحلة جديدة
</Link>
</label>
</div>
<p style={sectionHeadingStyle}>تفاصيل الشحنة</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
نوع الشحنة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("type")} placeholder="عادي / مبرد" dir="rtl" />
</label>
<label style={labelStyle}>
الكمية *
<input type="number" min={1} style={withError(!!errors.quantity)} {...numberField("quantity")} dir="ltr" />
{errors.quantity && <span style={errorTextStyle}>{errors.quantity.message}</span>}
</label>
<label style={labelStyle}>
الوزن (كجم)
<span style={optionalLabelStyle}>(اختياري)</span>
<input type="number" min={0} step="0.01" style={inputBase} {...numberField("weight")} dir="ltr" />
</label>
</div>
<p style={sectionHeadingStyle}>بيانات الدفع</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
الإجمالي الفرعي
<span style={optionalLabelStyle}>(اختياري)</span>
<input type="number" min={0} step="0.01" style={withError(!!errors.subTotal)} {...numberField("subTotal")} dir="ltr" />
{errors.subTotal && <span style={errorTextStyle}>{errors.subTotal.message}</span>}
</label>
<label style={labelStyle}>
نسبة الضريبة (%)
<span style={optionalLabelStyle}>(اختياري)</span>
<input type="number" min={0} step="0.01" style={withError(!!errors.vatRate)} {...numberField("vatRate")} dir="ltr" />
{errors.vatRate && <span style={errorTextStyle}>{errors.vatRate.message}</span>}
</label>
<label style={labelStyle}>
طريقة الدفع
<span style={optionalLabelStyle}>(اختياري)</span>
<select style={{ ...inputBase, cursor: "pointer" }} {...register("paymentMethod")} dir="rtl">
<option value="">اختر الطريقة</option>
<option value="Cash">نقداً</option>
<option value="Card">بطاقة</option>
<option value="Prepaid">مدفوع مسبقاً</option>
</select>
</label>
{!isNew && (
<label style={labelStyle}>
حالة الدفع
<select style={{ ...inputBase, cursor: "pointer" }} {...register("paymentStatus")} dir="rtl">
<option value=""></option>
<option value="Pending">معلَّق</option>
<option value="Paid">مدفوع</option>
<option value="Failed">فشل</option>
<option value="Refunded">مُسترجع</option>
</select>
</label>
)}
</div>
<p style={sectionHeadingStyle}>عنوان التسليم</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
المدينة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("deliveryAddress.details.city")} placeholder="الرياض" dir="rtl" />
</label>
<label style={labelStyle}>
الحي
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("deliveryAddress.details.district")} placeholder="العليا" dir="rtl" />
</label>
<label style={labelStyle}>
الشارع
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("deliveryAddress.details.street")} placeholder="شارع الأمير محمد" dir="rtl" />
</label>
<label style={labelStyle}>
رقم المبنى
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("deliveryAddress.details.buildingNo")} placeholder="1234" dir="ltr" />
</label>
<label style={labelStyle}>
رقم الوحدة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("deliveryAddress.details.unitNo")} placeholder="56" dir="ltr" />
</label>
<label style={labelStyle}>
الرمز البريدي
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("deliveryAddress.details.zipCode")} placeholder="12345" dir="ltr" />
</label>
<label style={labelStyle}>
خط الطول (Longitude) *
<input
type="number"
step="any"
style={withError(!!deliveryErr?.location?.coordinates)}
{...coordField("deliveryAddress.location.coordinates.0")}
placeholder="46.6753"
dir="ltr"
/>
</label>
<label style={labelStyle}>
خط العرض (Latitude) *
<input
type="number"
step="any"
style={withError(!!deliveryErr?.location?.coordinates)}
{...coordField("deliveryAddress.location.coordinates.1")}
placeholder="24.7136"
dir="ltr"
/>
{deliveryErr?.location?.coordinates && (
<span style={errorTextStyle}>{deliveryErr.location.coordinates.message}</span>
)}
</label>
</div>
<p style={sectionHeadingStyle}>عنوان الاستلام</p>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.25rem" }}>
{(["id", "new"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setPickupMode(m)}
style={{
height: 32,
padding: "0 0.875rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${pickupMode === m ? "var(--color-brand-600)" : "var(--color-border)"}`,
background: pickupMode === m ? "var(--color-brand-50, #EFF6FF)" : "var(--color-surface)",
fontSize: 12,
fontWeight: 600,
color: pickupMode === m ? "var(--color-brand-600)" : "var(--color-text-secondary)",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}}
>
{m === "id" ? "عنوان موجود (ID)" : "عنوان جديد"}
</button>
))}
</div>
{pickupMode === "id" ? (
<label style={labelStyle}>
معرّف العنوان (MongoDB ID)
<input
style={inputBase}
{...register("pickupAddressId")}
placeholder="67a1b2c3d4e5f6a7b8c9d0e1"
dir="ltr"
autoComplete="off"
/>
</label>
) : (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
المدينة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("pickupAddress.details.city")} placeholder="جدة" dir="rtl" />
</label>
<label style={labelStyle}>
الحي
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("pickupAddress.details.district")} placeholder="الروضة" dir="rtl" />
</label>
<label style={labelStyle}>
الشارع
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("pickupAddress.details.street")} placeholder="شارع التحلية" dir="rtl" />
</label>
<label style={labelStyle}>
رقم المبنى
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("pickupAddress.details.buildingNo")} placeholder="4321" dir="ltr" />
</label>
<label style={labelStyle}>
رقم الوحدة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("pickupAddress.details.unitNo")} placeholder="12" dir="ltr" />
</label>
<label style={labelStyle}>
الرمز البريدي
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("pickupAddress.details.zipCode")} placeholder="23456" dir="ltr" />
</label>
<label style={labelStyle}>
خط الطول (Longitude)
<span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number"
step="any"
style={withError(!!pickupErr?.location?.coordinates)}
{...coordField("pickupAddress.location.coordinates.0")}
placeholder="46.6753"
dir="ltr"
/>
</label>
<label style={labelStyle}>
خط العرض (Latitude)
<span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number"
step="any"
style={withError(!!pickupErr?.location?.coordinates)}
{...coordField("pickupAddress.location.coordinates.1")}
placeholder="24.7136"
dir="ltr"
/>
{pickupErr?.location?.coordinates && (
<span style={errorTextStyle}>{pickupErr.location.coordinates.message}</span>
)}
</label>
</div>
)}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button
type="button" type="button"
onClick={onClose} size="sm"
disabled={isSubmitting} variant={pickupMode === m ? "primary" : "secondary"}
style={{ onClick={() => setPickupMode(m)}
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: isSubmitting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
> >
إلغاء {m === "id" ? "عنوان موجود (ID)" : "عنوان جديد"}
</button> </Button>
<button ))}
type="submit" </div>
disabled={isSubmitting}
style={{ {pickupMode === "id" ? (
height: 40, <Input
padding: "0 1.5rem", id="pickup-addressId"
borderRadius: "var(--radius-md)", label="معرّف العنوان (MongoDB ID)"
border: "none", {...register("pickupAddressId")}
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)", placeholder="67a1b2c3d4e5f6a7b8c9d0e1"
fontSize: 13, dir="ltr"
fontWeight: 700, autoComplete="off"
color: "#FFF", />
cursor: isSubmitting ? "not-allowed" : "pointer", ) : (
display: "flex", <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
alignItems: "center", <Input
gap: 8, id="pickup-city"
fontFamily: "var(--font-sans)", label="المدينة (اختياري)"
}} {...register("pickupAddress.details.city")}
> placeholder="جدة"
{isSubmitting && <Spinner size="sm" className="text-white" />} dir="rtl"
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إنشاء الطلب" : "حفظ التغييرات"} />
</button> <Input
id="pickup-district"
label="الحي (اختياري)"
{...register("pickupAddress.details.district")}
placeholder="الروضة"
dir="rtl"
/>
<Input
id="pickup-street"
label="الشارع (اختياري)"
{...register("pickupAddress.details.street")}
placeholder="شارع التحلية"
dir="rtl"
/>
<Input
id="pickup-buildingNo"
label="رقم المبنى (اختياري)"
{...register("pickupAddress.details.buildingNo")}
placeholder="4321"
dir="ltr"
/>
<Input
id="pickup-unitNo"
label="رقم الوحدة (اختياري)"
{...register("pickupAddress.details.unitNo")}
placeholder="12"
dir="ltr"
/>
<Input
id="pickup-zipCode"
label="الرمز البريدي (اختياري)"
{...register("pickupAddress.details.zipCode")}
placeholder="23456"
dir="ltr"
/>
<Input
id="pickup-lng"
label="خط الطول (Longitude) (اختياري)"
type="number"
step="any"
error={pickupErr?.location?.coordinates ? " " : undefined}
{...coordField("pickupAddress.location.coordinates.0")}
placeholder="46.6753"
dir="ltr"
/>
<Input
id="pickup-lat"
label="خط العرض (Latitude) (اختياري)"
type="number"
step="any"
error={pickupErr?.location?.coordinates?.message}
{...coordField("pickupAddress.location.coordinates.1")}
placeholder="24.7136"
dir="ltr"
/>
</div> </div>
</form> )}
</div> </form>
</div> </Modal>
); );
} }

View File

@@ -0,0 +1,113 @@
"use client";
import { ReusableTable, ActionButtons } from "../UI";
import { ORDER_STATUS_MAP } from "./OrderDetailModel";
import type { TableColumn } from "@/src/types/models";
import type { Order } from "@/src/types/order";
function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
function fmtAmount(n?: number | null): string {
if (n == null) return "—";
return `${n.toFixed(2)} SAR`;
}
interface OrderTableProps {
orders: Order[];
loading: boolean;
search: string;
page: number;
pages: number;
setPage: React.Dispatch<React.SetStateAction<number>>;
onRowClick: (orderId: string) => void;
onEdit: (order: Order) => void;
onDelete: (order: Order) => void;
}
export function OrderTable({
orders, loading, search, page, pages,
setPage, onRowClick, onEdit, onDelete,
}: OrderTableProps) {
const columns: TableColumn<Order>[] = [
{
key: "shipmentNumber",
header: "رقم الشحنة #",
width: "1.6fr",
render: (o) => (
<div>
<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>
</div>
),
},
{
key: "recipient",
header: "المستلم",
width: "1.6fr",
render: (o) => (
<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>
),
},
{ key: "client", header: "العميل", width: "1.2fr", render: (o) => o.clientId ?? "—" },
{
key: "amount",
header: "الكمية",
width: "1.1fr",
render: (o) => (
<span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}>
{o.quantity ?? "—"}
</span>
),
},
{
key: "status",
header: "الحالة",
width: "1fr",
render: (o) => {
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
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={orders}
loading={loading}
search={search}
page={page}
pages={pages}
onPageChange={setPage}
actionsWidth="0.8fr"
onRowClick={(o) => onRowClick(o.id)}
emptyDescription={search ? `No results for "${search}"` : undefined}
renderActions={(o) => (
<ActionButtons
itemLabel={o.shipmentNumber}
onEdit={() => onEdit(o)}
onDelete={() => onDelete(o)}
/>
)}
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { ORDER_STATUS_MAP } from "../OrderDetailBanel"; import { Button, Modal } from "../../UI";
import { ORDER_STATUS_MAP } from "../OrderDetailModel";
import type { ArchivedOrder } from "@/src/types/order"; import type { ArchivedOrder } from "@/src/types/order";
interface ArchivedOrderDetailModalProps { interface ArchivedOrderDetailModalProps {
@@ -34,60 +35,47 @@ export function ArchivedOrderDetailModal({ order, onClose }: ArchivedOrderDetail
const s = ORDER_STATUS_MAP[order.currentStatus] ?? ORDER_STATUS_MAP.Created; const s = ORDER_STATUS_MAP[order.currentStatus] ?? ORDER_STATUS_MAP.Created;
return ( return (
<div role="dialog" aria-modal="true" aria-labelledby="archived-order-title" <Modal
onClick={e => { if (e.target === e.currentTarget) onClose(); }} open
style={{ position: "fixed", inset: 0, zIndex: 80, background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}> title={order.shipmentNumber}
<div onClick={e => e.stopPropagation()} subtitle="طلب مؤرشف"
style={{ width: "100%", maxWidth: 480, 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" }}> onClose={onClose}
<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)" }}> zIndex={60}
<div> size="md"
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>طلب مؤرشف</p> footer={
<h2 id="archived-order-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0", fontFamily: "var(--font-mono)" }}> <Button type="button" variant="secondary" onClick={onClose}>
{order.shipmentNumber} إغلاق
</h2> </Button>
}
>
<div dir="rtl">
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: s.color, marginBottom: 12 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: s.dot }} />
{s.label}
</span>
<DetailRow label="اسم المستلم" value={order.recipientName} />
<DetailRow label="رقم الجوال" value={order.recipientPhone} mono />
<DetailRow label="الكمية" value={String(order.quantity)} />
<DetailRow label="الإجمالي الفرعي" value={fmtAmount(order.subTotal)} mono />
<DetailRow label="تاريخ الإنشاء" value={fmtDate(order.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(order.updatedAt)} />
{order.statusHistory && order.statusHistory.length > 0 && (
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 6 }}>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "0 0 4px" }}>سجل الحالات</p>
{order.statusHistory.map(h => {
const hs = 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 ${hs.border}`, background: hs.bg, padding: "0.5rem 0.875rem" }}>
<span style={{ fontSize: 12, fontWeight: 600, color: hs.color }}>{hs.label}{h.reason ? `${h.reason}` : ""}</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(h.createdAt)}</span>
</div>
);
})}
</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" }}>
×
</button>
</div>
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }} dir="rtl">
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: s.color, marginBottom: 12 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: s.dot }} />
{s.label}
</span>
<DetailRow label="اسم المستلم" value={order.recipientName} />
<DetailRow label="رقم الجوال" value={order.recipientPhone} mono />
<DetailRow label="الكمية" value={String(order.quantity)} />
<DetailRow label="الإجمالي الفرعي" value={fmtAmount(order.subTotal)} mono />
<DetailRow label="تاريخ الإنشاء" value={fmtDate(order.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(order.updatedAt)} />
{order.statusHistory && order.statusHistory.length > 0 && (
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 6 }}>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "0 0 4px" }}>سجل الحالات</p>
{order.statusHistory.map(h => {
const hs = 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 ${hs.border}`, background: hs.bg, padding: "0.5rem 0.875rem" }}>
<span style={{ fontSize: 12, fontWeight: 600, color: hs.color }}>{hs.label}{h.reason ? `${h.reason}` : ""}</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(h.createdAt)}</span>
</div>
);
})}
</div>
)}
</div>
<div style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", display: "flex", justifyContent: "flex-end" }}>
<button type="button" onClick={onClose}
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>
</div> </div>
</div> </Modal>
); );
} }

View File

@@ -1,126 +1,80 @@
"use client"; "use client";
import { Spinner } from "../../UI"; import { ReusableTable, ActionButtons } from "../../UI";
import { ORDER_STATUS_MAP } from "../OrderDetailBanel"; 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 ? ( ),
<div style={{ textAlign: "center", padding: "4rem 1rem" }}> },
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}> {
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد طلبات في الأرشيف."} key: "amount",
</p> header: "الكمية",
</div> width: "1fr",
) : ( render: (o) => <span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "var(--color-text-primary)" }}>{fmtAmount(o.subTotal)}</span>,
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> },
{orders.map((o, i) => { {
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created; key: "status",
return ( header: "الحالة",
<li key={o.id} style={{ width: "1fr",
display: "grid", gridTemplateColumns: ROW_GRID, render: (o) => {
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem", const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
borderBottom: "1px solid var(--color-border)", return (
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", <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" }}>
fontSize: 13, <span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
}}> {s.label}
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB" }}>
{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" }}>
<button type="button" title={`عرض ${o.shipmentNumber}`} aria-label={`عرض ${o.shipmentNumber}`}
onClick={e => { e.stopPropagation(); onView(o); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: "1px solid #A7F3D0", background: "#ECFDF5", color: "#059669", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<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>
</button>
</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" }}> );
{[ },
{ 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" onClick={btn.action} disabled={btn.disabled} return (
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)" }}> <ReusableTable
{btn.label} columns={columns}
</button> data={orders}
))} loading={loading}
</div> search={search}
</div> page={page}
)} pages={pages}
</div> onPageChange={onPageChange}
emptyIcon="🗄️"
emptyDescription={search ? `No results for "${search}"` : "No archived orders."}
renderActions={(o) => <ActionButtons itemLabel={o.shipmentNumber} onView={() => onView(o)} />}
/>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "../../UI"; import { Alert, Input, Modal } from "../../UI";
import { ArchivedOrderTable } from "./ArchivedOrderTable"; import { ArchivedOrderTable } from "./ArchivedOrderTable";
import { ArchivedOrderDetailModal } from "./ArchivedOrderDetailModal"; import { ArchivedOrderDetailModal } from "./ArchivedOrderDetailModal";
import { useArchivedOrders } from "@/src/hooks/archive/useArchivedOrders"; import { useArchivedOrders } from "@/src/hooks/archive/useArchivedOrders";
@@ -11,6 +11,10 @@ interface ArchivedOrdersModalProps {
onClose: () => void; onClose: () => void;
} }
const searchIcon = (
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
);
export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) { export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
const [viewOrder, setViewOrder] = useState<ArchivedOrder | null>(null); const [viewOrder, setViewOrder] = useState<ArchivedOrder | null>(null);
@@ -21,79 +25,28 @@ export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
} = useArchivedOrders(); } = useArchivedOrders();
return ( return (
<div <>
role="dialog" aria-modal="true" aria-labelledby="archive-orders-modal-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 70,
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "flex-start", justifyContent: "center",
padding: "2rem 1rem", overflowY: "auto",
}}
>
{viewOrder && ( {viewOrder && (
<ArchivedOrderDetailModal order={viewOrder} onClose={() => setViewOrder(null)} /> <ArchivedOrderDetailModal order={viewOrder} onClose={() => setViewOrder(null)} />
)} )}
<div <Modal
onClick={e => e.stopPropagation()} open
style={{ onClose={onClose}
width: "100%", maxWidth: 920, size="lg"
background: "var(--color-surface-sunken)", subtitle="الأرشيف"
borderRadius: "var(--radius-xl)", title={`الطلبات المؤرشفة (${total})`}
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
overflow: "hidden",
}}
> >
{/* header */} <div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-orders-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
الطلبات المؤرشفة
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</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 */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */} {/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}> <div style={{ maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }} <Input
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> label="بحث"
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="رقم الشحنة أو المستلم..." placeholder="رقم الشحنة أو المستلم..."
value={search} value={search}
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
icon={searchIcon}
dir="rtl" 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, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/> />
</div> </div>
@@ -109,7 +62,7 @@ export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
onPageChange={setPage} onPageChange={setPage}
/> />
</div> </div>
</div> </Modal>
</div> </>
); );
} }

View File

@@ -1,2 +1,3 @@
export {OrderFormModal} from './OrderFormModal'; export {OrderFormModal} from './OrderFormModal';
export {OrderDetailPanel} from './OrderDetailBanel'; export {OrderDetailPanel, ORDER_STATUS_MAP, PAY_STATUS_MAP, PAY_METHOD_LABEL} from './OrderDetailModel';
export {OrderTable} from './OrderTable';

View 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;

View File

@@ -0,0 +1,152 @@
"use client";
import { useEffect, useState } from "react";
import { Alert, Button, Modal, Spinner } from "../UI";
import { getStoredToken } from "@/src/lib/auth";
import { tripService } from "@/src/services/trip.service";
import { TRIP_STATUS_MAP } from "@/src/types/trip";
import type { Trip } from "@/src/types/trip";
interface TripDetailModalProps {
tripId: string;
onClose: () => void;
}
// ── small helper components ───────────────────────────────────────────────────
// (No shared-UI equivalents — purpose-built layouts, not generic controls.)
function DetailRow({ label, value }: { label: string; value?: string | 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: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
{label}
</span>
<span style={{ fontSize: 13, fontWeight: 500, color: value ? "var(--color-text-primary)" : "var(--color-text-hint)" }}>
{value || "—"}
</span>
</div>
);
}
function StatusBadge({ status }: { status: Trip["status"] }) {
const s = TRIP_STATUS_MAP[status];
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: `1px solid ${s.border}`,
background: s.bg,
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: s.color,
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
{s.label}
</span>
);
}
// ── main component ────────────────────────────────────────────────────────────
export function TripDetailModal({ tripId, onClose }: TripDetailModalProps) {
const [trip, setTrip] = useState<Trip | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// fetch trip details on mount
useEffect(() => {
let cancelled = false;
(async () => {
try {
const token = getStoredToken();
const data = await tripService.getById(tripId, token);
if (!cancelled) setTrip(data);
} catch {
if (!cancelled) setError("تعذّر تحميل بيانات الرحلة. يرجى المحاولة لاحقاً.");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [tripId]);
// ── helpers ───────────────────────────────────────────────────────────────
const fmt = (iso?: string | null) =>
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" }) : null;
const cash = (v?: number | string | null) =>
v != null ? `${Number(v).toLocaleString("ar-SA")} ر.س` : null;
// ── render ────────────────────────────────────────────────────────────────
return (
<Modal
open
title={trip?.title ?? "عرض الرحلة"}
subtitle="تفاصيل الرحلة"
onClose={onClose}
size="md"
footer={
<Button type="button" variant="secondary" onClick={onClose}>
إغلاق
</Button>
}
>
{/* loading */}
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error */}
{!loading && error && <Alert type="error" message={error} />}
{/* content */}
{!loading && trip && (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
{/* title + trip number + status row */}
<div style={{
display: "flex", flexDirection: "column", gap: 8,
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{trip.title}</p>
<p style={{ margin: 0, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
{trip.tripNumber}
</p>
<StatusBadge status={trip.status} />
</div>
{/* driver / car / branch */}
<DetailRow label="السائق" value={trip.driver?.name} />
<DetailRow label="السيارة" value={trip.car ? `${trip.car.manufacturer} ${trip.car.model}${trip.car.plateNumber}` : null} />
<DetailRow label="الفرع" value={trip.branch?.name} />
{/* timing */}
<DetailRow label="وقت البدء" value={fmt(trip.startTime)} />
<DetailRow label="وقت الانتهاء" value={fmt(trip.endTime)} />
{/* counts + cash */}
<DetailRow label="المجمّع" value={String(trip.collectedCount)} />
<DetailRow label="المُسلَّم" value={String(trip.deliveredCount)} />
<DetailRow label="المُرتجع" value={String(trip.returnedCount)} />
<DetailRow label="النقد المحصّل" value={cash(trip.totalCashCollected)} />
{/* notes */}
<DetailRow label="ملاحظات" value={trip.notes} />
<DetailRow label="سبب الإنهاء" value={trip.endReason} />
{/* timestamps */}
<DetailRow label="تاريخ الإنشاء" value={fmt(trip.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(trip.updatedAt)} />
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
// src/Components/Trip/TripTable.tsx
// MIGRATED to ReusableTable + ActionButtons — same props, same visual
// result as the original. StatusBadge kept custom since colors come
// dynamically from TRIP_STATUS_MAP (per-status color+dot).
import { Button, ReusableTable, ActionButtons } from "../UI";
import { TRIP_STATUS_MAP } from "@/src/types/trip";
import type { TableColumn } from "@/src/types/models";
import type { Trip } from "@/src/types/trip";
function StatusBadge({ status }: { status: Trip["status"] }) {
const s = TRIP_STATUS_MAP[status];
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 }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
{s.label}
</span>
);
}
function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
interface TripTableProps {
trips: Trip[];
loading: boolean;
search: string;
page: number;
pages: number;
onEdit: (trip: Trip) => void;
onDelete: (trip: Trip) => void;
onView: (trip: Trip) => void;
onAddFirst: () => void;
onPageChange: (p: number) => void;
}
export function TripTable({
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 (
<ReusableTable
columns={columns}
data={trips}
loading={loading}
search={search}
page={page}
pages={pages}
onPageChange={onPageChange}
emptyIcon="🚚"
emptyDescription={!search ? "No trips to display." : undefined}
emptyAction={
!search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
Add first trip
</Button>
)
}
renderActions={(t) => (
<ActionButtons
itemLabel={t.title}
onView={() => onView(t)}
onEdit={() => onEdit(t)}
onDelete={() => onDelete(t)}
/>
)}
/>
);
}

View 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>
);
}

View File

@@ -1,10 +1,11 @@
"use client"; "use client";
import { useEffect, useRef, 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, Spinner } 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,
@@ -21,35 +22,11 @@ import type { DriverOption } from "@/src/types/driver";
import type { CarOption } from "@/src/types/car"; import type { CarOption } from "@/src/types/car";
import type { BranchOption } from "@/src/types/branch"; import type { BranchOption } from "@/src/types/branch";
const FORM_ID = "trip-form";
// ── Shared styles ──────────────────────────────────────────────────────────── // ── Shared styles ────────────────────────────────────────────────────────────
// Only for section headings — everything field-level now comes from the
const inputBase: React.CSSProperties = { // Input / Select / Textarea components themselves.
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)",
};
const errorTextStyle: React.CSSProperties = {
fontSize: 11,
color: "var(--color-danger)",
fontWeight: 500,
};
const sectionHeadingStyle: React.CSSProperties = { const sectionHeadingStyle: React.CSSProperties = {
fontSize: 11, fontSize: 11,
@@ -60,13 +37,6 @@ const sectionHeadingStyle: React.CSSProperties = {
margin: "0.5rem 0 0", margin: "0.5rem 0 0",
}; };
const optionalLabelStyle: React.CSSProperties = {
fontSize: 10,
fontWeight: 500,
color: "var(--color-text-hint)",
marginRight: 4,
};
// ── Form values shape ──────────────────────────────────────────────────────── // ── Form values shape ────────────────────────────────────────────────────────
// startTime/endTime store the ISO-8601 string (via setValueAs on register), // startTime/endTime store the ISO-8601 string (via setValueAs on register),
// while the <input type="datetime-local"> DOM element itself keeps showing // while the <input type="datetime-local"> DOM element itself keeps showing
@@ -115,26 +85,34 @@ export function TripFormModal({
const [cars, setCars] = useState<CarOption[]>([]); const [cars, setCars] = useState<CarOption[]>([]);
const [branches, setBranches] = useState<BranchOption[]>([]); const [branches, setBranches] = useState<BranchOption[]>([]);
useEffect(() => {
const token = getStoredToken();
driverService.getActiveOptions(token).then(setDrivers).catch(() => {});
carService.getActiveOptions(token).then(setCars).catch(() => {});
branchService.getOptions(token).then(setBranches).catch(() => {});
}, []);
// ── react-hook-form ──────────────────────────────────────────────────────── // ── react-hook-form ────────────────────────────────────────────────────────
// Moved above the dropdown-loading effect below: that effect calls
// setValue(), so useForm() (which defines it) must run first — otherwise
// setValue is referenced before its own initialization (TDZ ReferenceError).
const { const {
register, register,
handleSubmit, handleSubmit,
setError, setError,
setFocus,
setValue,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<TripFormValues>({ } = useForm<TripFormValues>({
resolver: yupResolver(isNew ? createTripSchema : updateTripSchema) as never, // Cast both the schema argument and the resolver's result:
// - argument: createTripSchema/updateTripSchema have structurally
// different shapes (create requires title/driverId/etc., update makes
// everything optional), so the ternary's type is a union yupResolver's
// single-schema signature won't accept.
// - result: yup's inferred type for .optional() fields
// (`collectedCount?: number`) is structurally incompatible with
// TripFormValues's `collectedCount: number | undefined` — letting
// useForm<TripFormValues> alone establish the field-values type avoids
// that mismatch.
resolver: yupResolver((isNew ? createTripSchema : updateTripSchema) as any) as any,
defaultValues: { defaultValues: {
title: editTrip?.title ?? "", title: editTrip?.title ?? "",
driverId: editTrip?.driverId ?? "", driverId: editTrip?.driverId ?? editTrip?.driver?.id ?? "",
carId: editTrip?.carId ?? "", carId: editTrip?.carId ?? editTrip?.car?.id ?? "",
branchId: editTrip?.branchId ?? "", branchId: editTrip?.branchId ?? editTrip?.branch?.id ?? "",
status: editTrip?.status ?? "Scheduled", status: editTrip?.status ?? "Scheduled",
startTime: editTrip?.startTime?.slice(0, 16) ?? "", startTime: editTrip?.startTime?.slice(0, 16) ?? "",
endTime: editTrip?.endTime?.slice(0, 16) ?? "", endTime: editTrip?.endTime?.slice(0, 16) ?? "",
@@ -149,24 +127,34 @@ export function TripFormModal({
}, },
}); });
// ── Fetch dropdown options once on mount ────────────────────────────────────
// 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(setDrivers).catch(() => {});
carService.getActiveOptions(token).then(setCars).catch(() => {});
branchService.getOptions(token).then(setBranches).catch(() => {});
}, []);
// CHANGE: replaced the three manual `if (savedXId) setValue(...)` calls
// (previously inlined inside each .then() above) with useEditFormSync —
// each hook call re-applies the saved id once its matching option list
// has loaded, regardless of which fetch resolves first.
useEditFormSync(setValue, "driverId", editTrip?.driverId ?? editTrip?.driver?.id, drivers);
useEditFormSync(setValue, "carId", editTrip?.carId ?? editTrip?.car?.id, cars);
useEditFormSync(setValue, "branchId", editTrip?.branchId ?? editTrip?.branch?.id, branches);
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");
const firstRef = useRef<HTMLInputElement>(null);
// register("title") already attaches its own ref — Input forwards it
// through to the underlying <input>, so setFocus works with no separate
// ref needed here.
useEffect(() => { useEffect(() => {
firstRef.current?.focus(); setFocus("title");
}, []); }, [setFocus]);
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
const withError = (hasError: boolean): React.CSSProperties => ({
...inputBase,
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
const numberField = ( const numberField = (
field: "collectedCount" | "deliveredCount" | "returnedCount" | "totalCashCollected", field: "collectedCount" | "deliveredCount" | "returnedCount" | "totalCashCollected",
@@ -182,6 +170,9 @@ export function TripFormModal({
setValueAs: (v) => (v ? `${v}:00.000Z` : ""), setValueAs: (v) => (v ? `${v}:00.000Z` : ""),
}); });
const titleError =
errors.title && errors.title.type !== "manual" ? errors.title.message : undefined;
// ── Submit ──────────────────────────────────────────────────────────────── // ── Submit ────────────────────────────────────────────────────────────────
const submitHandler = async (data: TripFormValues) => { const submitHandler = async (data: TripFormValues) => {
@@ -219,302 +210,205 @@ export function TripFormModal({
}; };
return ( return (
<div <Modal
role="dialog" open
aria-modal="true" onClose={onClose}
aria-labelledby="trip-form-title" size="lg"
onClick={(e) => { title={isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"}
if (e.target === e.currentTarget) onClose(); footer={
}} <>
style={{ <Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
position: "fixed", إلغاء
inset: 0, </Button>
zIndex: 60, <Button type="submit" form={FORM_ID} variant="primary" loading={isSubmitting}>
background: "rgba(15,23,42,0.55)", {isNew ? "إضافة الرحلة" : "حفظ التغييرات"}
backdropFilter: "blur(4px)", </Button>
display: "flex", </>
alignItems: "flex-start", }
justifyContent: "center",
padding: "2rem 1rem",
overflowY: "auto",
}}
> >
<div <form
onClick={(e) => e.stopPropagation()} id={FORM_ID}
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", noValidate
maxWidth: 700, dir="rtl"
background: "var(--color-surface)", className="flex flex-col gap-4"
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 56px rgba(0,0,0,.2)",
overflow: "hidden",
}}
> >
{/* Header */} {errors.title?.type === "manual" && (
<div <Alert type="error" message={errors.title.message ?? ""} onClose={() => setApiError("")} />
style={{ )}
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)", {/* ── Section: trip management ── */}
background: "var(--color-surface-muted)", <p style={sectionHeadingStyle}>أساسيات الرحلة</p>
display: "flex",
alignItems: "center", {/* Title */}
justifyContent: "space-between", <Input
}} label="عنوان الرحلة *"
> error={titleError}
<h2 {...register("title")}
id="trip-form-title" placeholder="مثال: توزيع الرياض الشمالي"
style={{ dir="rtl"
fontSize: 16, />
fontWeight: 700,
color: "var(--color-text-primary)", <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
margin: 0, {/* Driver */}
}} <Select
label="السائق *"
error={errors.driverId?.message}
{...register("driverId")}
dir="rtl"
> >
{isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"} <option value="">اختر السائق</option>
</h2> {drivers.map((d) => (
<button <option key={d.id} value={d.id}>
type="button" {d.name} {d.phone}
onClick={onClose} </option>
style={{ ))}
background: "none", </Select>
border: "none",
cursor: "pointer", {/* Car */}
color: "var(--color-text-muted)", <Select
padding: 4, label="السيارة *"
}} error={errors.carId?.message}
{...register("carId")}
dir="rtl"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <option value="">اختر السيارة</option>
<line x1="18" y1="6" x2="6" y2="18" /> {cars.map((c) => (
<line x1="6" y1="6" x2="18" y2="18" /> <option key={c.id} value={c.id}>
</svg> {c.manufacturer} {c.model} {c.plateNumber}
</button> </option>
))}
</Select>
</div> </div>
{/* Form */} <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<form {/* Branch */}
onSubmit={handleSubmit(submitHandler)} <Select
noValidate label="الفرع *"
style={{ error={errors.branchId?.message}
padding: "1.5rem", {...register("branchId")}
display: "flex", dir="rtl"
flexDirection: "column", >
gap: "1rem", <option value="">اختر الفرع</option>
}} {branches.map((b) => (
> <option key={b.id} value={b.id}>{b.name}</option>
{errors.title?.type === "manual" && ( ))}
<Alert type="error" message={errors.title.message ?? ""} onClose={() => setApiError("")} /> </Select>
)}
{/* ── Section:trip management── */} {/* Status */}
<p style={sectionHeadingStyle}>أساسيات الرحلة</p> <Select label="الحالة" {...register("status")} dir="rtl">
<option value="Scheduled">مجدولة</option>
<option value="InProgress">جارية</option>
<option value="Completed">مكتملة</option>
<option value="Cancelled">ملغاة</option>
</Select>
</div>
{/* Title */} {/* ── Section: التوقيت ── */}
<label style={labelStyle}> <p style={sectionHeadingStyle}>التوقيت</p>
عنوان الرحلة <span style={{ color: "var(--color-danger)" }}>*</span>
<input <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
ref={firstRef} <Input
style={withError(!!errors.title && errors.title.type !== "manual")} label="وقت البدء"
{...register("title")} hint="اختياري"
placeholder="مثال: توزيع الرياض الشمالي" type="datetime-local"
error={errors.startTime?.message}
{...dateTimeField("startTime")}
/>
<Input
label="وقت الانتهاء"
hint="اختياري"
type="datetime-local"
error={errors.endTime?.message}
{...dateTimeField("endTime")}
/>
</div>
{/* ── Section: الأعداد والمبالغ ── */}
<p style={sectionHeadingStyle}>الأعداد والمبالغ</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: "0.75rem" }}>
<Input
label="المجمّع"
hint="اختياري"
type="number"
min="0"
error={errors.collectedCount?.message}
{...numberField("collectedCount")}
placeholder="0"
dir="ltr"
/>
<Input
label="المُسلَّم"
hint="اختياري"
type="number"
min="0"
error={errors.deliveredCount?.message}
{...numberField("deliveredCount")}
placeholder="0"
dir="ltr"
/>
<Input
label="المُرتجع"
hint="اختياري"
type="number"
min="0"
error={errors.returnedCount?.message}
{...numberField("returnedCount")}
placeholder="0"
dir="ltr"
/>
<Input
label="النقد المحصّل"
hint="اختياري"
type="number"
min="0"
step="0.01"
error={errors.totalCashCollected?.message}
{...numberField("totalCashCollected")}
placeholder="0.00"
dir="ltr"
/>
</div>
{/* ── Section: ملاحظات ── */}
<p style={sectionHeadingStyle}>ملاحظات</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Textarea
label="ملاحظات"
hint="اختياري"
error={errors.notes?.message}
{...register("notes")}
placeholder="أي ملاحظات إضافية…"
dir="rtl"
className="h-[72px]"
/>
<Textarea
label="سبب الإنهاء"
hint="اختياري"
error={errors.endReason?.message}
{...register("endReason")}
placeholder="سبب إنهاء أو إلغاء الرحلة…"
dir="rtl"
className="h-[72px]"
/>
</div>
{/* reason — edit only (for reassigning driver/car) */}
{!isNew && (
<>
<p style={sectionHeadingStyle}>سجل التغيير</p>
<Input
label="سبب التعديل"
hint="مطلوب عند تغيير السائق أو السيارة"
{...register("reason")}
placeholder="مثال: تغيير السائق بسبب إجازة طارئة"
dir="rtl" dir="rtl"
/> />
{errors.title && errors.title.type !== "manual" && ( </>
<span style={errorTextStyle}>{errors.title.message}</span> )}
)} </form>
</label> </Modal>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
{/* Driver */}
<label style={labelStyle}>
السائق <span style={{ color: "var(--color-danger)" }}>*</span>
<select style={{ ...withError(!!errors.driverId), cursor: "pointer" }} {...register("driverId")} dir="rtl">
<option value="">اختر السائق</option>
{drivers.map((d) => (
<option key={d.id} value={d.id}>
{d.name} {d.phone}
</option>
))}
</select>
{errors.driverId && <span style={errorTextStyle}>{errors.driverId.message}</span>}
</label>
{/* Car */}
<label style={labelStyle}>
السيارة <span style={{ color: "var(--color-danger)" }}>*</span>
<select style={{ ...withError(!!errors.carId), cursor: "pointer" }} {...register("carId")} dir="rtl">
<option value="">اختر السيارة</option>
{cars.map((c) => (
<option key={c.id} value={c.id}>
{c.manufacturer} {c.model} {c.plateNumber}
</option>
))}
</select>
{errors.carId && <span style={errorTextStyle}>{errors.carId.message}</span>}
</label>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
{/* Branch */}
<label style={labelStyle}>
الفرع <span style={{ color: "var(--color-danger)" }}>*</span>
<select style={{ ...withError(!!errors.branchId), cursor: "pointer" }} {...register("branchId")} dir="rtl">
<option value="">اختر الفرع</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
{errors.branchId && <span style={errorTextStyle}>{errors.branchId.message}</span>}
</label>
{/* Status */}
<label style={labelStyle}>
الحالة
<select style={{ ...inputBase, cursor: "pointer" }} {...register("status")} dir="rtl">
<option value="Scheduled">مجدولة</option>
<option value="InProgress">جارية</option>
<option value="Completed">مكتملة</option>
<option value="Cancelled">ملغاة</option>
</select>
</label>
</div>
{/* ── Section: التوقيت ── */}
<p style={sectionHeadingStyle}>التوقيت</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
وقت البدء <span style={optionalLabelStyle}>(اختياري)</span>
<input type="datetime-local" style={withError(!!errors.startTime)} {...dateTimeField("startTime")} />
{errors.startTime && <span style={errorTextStyle}>{errors.startTime.message}</span>}
</label>
<label style={labelStyle}>
وقت الانتهاء <span style={optionalLabelStyle}>(اختياري)</span>
<input type="datetime-local" style={withError(!!errors.endTime)} {...dateTimeField("endTime")} />
{errors.endTime && <span style={errorTextStyle}>{errors.endTime.message}</span>}
</label>
</div>
{/* ── Section: الأعداد والمبالغ ── */}
<p style={sectionHeadingStyle}>الأعداد والمبالغ</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
المجمّع <span style={optionalLabelStyle}>(اختياري)</span>
<input type="number" min="0" style={withError(!!errors.collectedCount)} {...numberField("collectedCount")} placeholder="0" dir="ltr" />
{errors.collectedCount && <span style={errorTextStyle}>{errors.collectedCount.message}</span>}
</label>
<label style={labelStyle}>
المُسلَّم <span style={optionalLabelStyle}>(اختياري)</span>
<input type="number" min="0" style={withError(!!errors.deliveredCount)} {...numberField("deliveredCount")} placeholder="0" dir="ltr" />
{errors.deliveredCount && <span style={errorTextStyle}>{errors.deliveredCount.message}</span>}
</label>
<label style={labelStyle}>
المُرتجع <span style={optionalLabelStyle}>(اختياري)</span>
<input type="number" min="0" style={withError(!!errors.returnedCount)} {...numberField("returnedCount")} placeholder="0" dir="ltr" />
{errors.returnedCount && <span style={errorTextStyle}>{errors.returnedCount.message}</span>}
</label>
<label style={labelStyle}>
النقد المحصّل <span style={optionalLabelStyle}>(اختياري)</span>
<input type="number" min="0" step="0.01" style={withError(!!errors.totalCashCollected)} {...numberField("totalCashCollected")} placeholder="0.00" dir="ltr" />
{errors.totalCashCollected && <span style={errorTextStyle}>{errors.totalCashCollected.message}</span>}
</label>
</div>
{/* ── Section: ملاحظات ── */}
<p style={sectionHeadingStyle}>ملاحظات</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
ملاحظات <span style={optionalLabelStyle}>(اختياري)</span>
<textarea
style={{ ...withError(!!errors.notes), height: 72, padding: "0.5rem 0.75rem", resize: "vertical" }}
{...register("notes")}
placeholder="أي ملاحظات إضافية…"
dir="rtl"
/>
{errors.notes && <span style={errorTextStyle}>{errors.notes.message}</span>}
</label>
<label style={labelStyle}>
سبب الإنهاء <span style={optionalLabelStyle}>(اختياري)</span>
<textarea
style={{ ...withError(!!errors.endReason), height: 72, padding: "0.5rem 0.75rem", resize: "vertical" }}
{...register("endReason")}
placeholder="سبب إنهاء أو إلغاء الرحلة…"
dir="rtl"
/>
{errors.endReason && <span style={errorTextStyle}>{errors.endReason.message}</span>}
</label>
</div>
{/* reason — edit only (for reassigning driver/car) */}
{!isNew && (
<>
<p style={sectionHeadingStyle}>سجل التغيير</p>
<label style={labelStyle}>
سبب التعديل{" "}
<span style={optionalLabelStyle}>(مطلوب عند تغيير السائق أو السيارة)</span>
<input
style={inputBase}
{...register("reason")}
placeholder="مثال: تغيير السائق بسبب إجازة طارئة"
dir="rtl"
/>
</label>
</>
)}
{/* ── Actions ── */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
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: isSubmitting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء
</button>
<button
type="submit"
disabled={isSubmitting}
style={{
height: 40,
padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "none",
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: isSubmitting ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{isSubmitting && <Spinner size="sm" className="text-white" />}
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة الرحلة" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
); );
} }

View File

@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { Alert, Input, Modal } from "@/src/Components/UI";
import { ArchivedTripTable } from "./ArchivedTripTable";
import { ArchivedTripDetailModal } from "./ArchivedtripDetailModal";
import { useArchivedTrips } from "@/src/hooks/archive/useArchivedTrips";
interface ArchivedTripModalProps {
onClose: () => void;
}
const searchIcon = (
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
);
/**
* Top-level modal for browsing archived trips. Wires the useArchivedTrips
* hook to the table and detail modal, mirroring ArchivedDrivers/
* ArchivedUsersModal's structure and behavior.
*/
export function ArchivedTripModal({ onClose }: ArchivedTripModalProps) {
const [viewTripId, setViewTripId] = useState<string | null>(null);
const {
trips, loading, total, pages, error,
page, search,
setPage, handleSearch, clearError,
} = useArchivedTrips();
return (
<>
{viewTripId && (
<ArchivedTripDetailModal tripId={viewTripId} onClose={() => setViewTripId(null)} />
)}
<Modal
open
onClose={onClose}
size="lg"
subtitle="الأرشيف"
title={`الرحلات المؤرشفة (${total})`}
>
<div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div style={{ maxWidth: 320 }}>
<Input
label="بحث"
placeholder="بحث برقم الرحلة أو العنوان..."
value={search}
onChange={e => handleSearch(e.target.value)}
icon={searchIcon}
dir="rtl"
/>
</div>
{error && <Alert type="error" message={error} onClose={clearError} />}
<ArchivedTripTable
trips={trips}
loading={loading}
search={search}
page={page}
pages={pages}
onView={trip => setViewTripId(trip.id)}
onPageChange={setPage}
/>
</div>
</Modal>
</>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { ReusableTable } from "@/src/Components/UI";
import { TRIP_STATUS_MAP } from "@/src/types/trip";
import type { TableColumn } from "@/src/types/models";
import type { Trip } from "@/src/types/trip";
function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
interface ArchivedTripTableProps {
trips: Trip[];
loading: boolean;
search: string;
page: number;
pages: number;
onView: (trip: Trip) => void;
onPageChange: (p: number) => void;
}
export function ArchivedTripTable({ trips, loading, search, page, pages, onView, onPageChange }: ArchivedTripTableProps) {
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-brand-600)" }}>{t.tripNumber}</p>
</div>
),
},
{
key: "status",
header: "الحالة",
width: "1.5fr",
render: (t) => {
const s = TRIP_STATUS_MAP[t.status];
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 }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
{s.label}
</span>
);
},
},
{ key: "start", header: "تاريخ البداية", width: "1fr", align: "center", render: (t) => fmtDate(t.startTime) },
{ key: "end", header: "تاريخ الانتهاء", width: "1fr", align: "center", render: (t) => fmtDate(t.endTime) },
{
key: "cash",
header: "المبلغ المحصل",
width: "1fr",
align: "center",
render: (t) => (
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)" }}>
{t.totalCashCollected != null ? `${Number(t.totalCashCollected).toLocaleString("en-US")} SAR` : "—"}
</span>
),
},
];
return (
<ReusableTable
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."}
/>
);
}

View File

@@ -0,0 +1,155 @@
"use client";
// src/Components/Trip/archive/ArchivedTripDetailModal.tsx
// New — mirrors ArchivedDriverDetailModal.tsx / ArchivedUserDetailModal.tsx.
// zIndex={60} because this always opens nested inside ArchivedTripModal.
import { useEffect, useState } from "react";
import { Alert, Button, Modal, Spinner } from "@/src/Components/UI";
import { getStoredToken } from "@/src/lib/auth";
import { archivedTripService } from "@/src/services/archive/archivedTrip.service";
import { TRIP_STATUS_MAP } from "@/src/types/trip";
import type { Trip } from "@/src/types/trip";
interface ArchivedTripDetailModalProps {
tripId: string;
onClose: () => void;
}
// ── small helper components ───────────────────────────────────────────────────
function DetailRow({ label, value }: { label: string; value?: string | 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: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
{label}
</span>
<span style={{ fontSize: 13, fontWeight: 500, color: value ? "var(--color-text-primary)" : "var(--color-text-hint)" }}>
{value || "—"}
</span>
</div>
);
}
function StatusBadge({ status }: { status: Trip["status"] }) {
const s = TRIP_STATUS_MAP[status];
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: `1px solid ${s.border}`,
background: s.bg,
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: s.color,
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
{s.label}
</span>
);
}
// ── main component ────────────────────────────────────────────────────────────
export function ArchivedTripDetailModal({ tripId, onClose }: ArchivedTripDetailModalProps) {
const [trip, setTrip] = useState<Trip | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// fetch archived trip details on mount
useEffect(() => {
let cancelled = false;
(async () => {
try {
const token = getStoredToken();
const data = await archivedTripService.getByIdUnwrapped(tripId, token);
if (!cancelled) setTrip(data);
} catch {
if (!cancelled) setError("تعذّر تحميل بيانات الرحلة المؤرشفة. يرجى المحاولة لاحقاً.");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [tripId]);
// ── helpers ───────────────────────────────────────────────────────────────
const fmt = (iso?: string | null) =>
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" }) : null;
const cash = (v?: number | string | null) =>
v != null ? `${Number(v).toLocaleString("ar-SA")} ر.س` : null;
// ── render ────────────────────────────────────────────────────────────────
return (
<Modal
open
title={trip?.title ?? "عرض الرحلة"}
subtitle="رحلة مؤرشفة"
onClose={onClose}
zIndex={60}
size="md"
footer={
<Button type="button" variant="secondary" onClick={onClose}>
إغلاق
</Button>
}
>
{/* loading */}
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error */}
{!loading && error && <Alert type="error" message={error} />}
{/* content */}
{!loading && trip && (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
{/* title + trip number + status row */}
<div style={{
display: "flex", flexDirection: "column", gap: 8,
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{trip.title}</p>
<p style={{ margin: 0, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
{trip.tripNumber}
</p>
<StatusBadge status={trip.status} />
</div>
{/* driver / car / branch */}
<DetailRow label="السائق" value={trip.driver?.name} />
<DetailRow label="السيارة" value={trip.car ? `${trip.car.manufacturer} ${trip.car.model}${trip.car.plateNumber}` : null} />
<DetailRow label="الفرع" value={trip.branch?.name} />
{/* timing */}
<DetailRow label="وقت البدء" value={fmt(trip.startTime)} />
<DetailRow label="وقت الانتهاء" value={fmt(trip.endTime)} />
{/* counts + cash */}
<DetailRow label="المجمّع" value={String(trip.collectedCount)} />
<DetailRow label="المُسلَّم" value={String(trip.deliveredCount)} />
<DetailRow label="المُرتجع" value={String(trip.returnedCount)} />
<DetailRow label="النقد المحصّل" value={cash(trip.totalCashCollected)} />
{/* notes */}
<DetailRow label="ملاحظات" value={trip.notes} />
<DetailRow label="سبب الإنهاء" value={trip.endReason} />
{/* timestamps */}
<DetailRow label="تاريخ الإنشاء" value={fmt(trip.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(trip.updatedAt)} />
</div>
)}
</Modal>
);
}

View File

@@ -1,3 +1,7 @@
// src/Components/Trip/index.ts // src/Components/Trip/index.ts
// CHANGE: removed TripDeleteModal export (Issue 1). // CHANGE: removed TripDeleteModal export (Issue 1).
export { TripFormModal } from "./Tripformmodal"; // CHANGE: split the old single-file Trip module into TripTable.tsx /
// TripFormModal.tsx / TripDetailModal.tsx, mirroring src/Components/User.
export { TripTable } from "./TripTable";
export { TripFormModal } from "./TripFormModal";
export { TripDetailModal } from "./TripDetailModal";

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Spinner ,Toast} from "../UI"; import { Button, Toast } from "../UI";
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 { TripNotification } from "@/src/hooks/useTrip"; import type { TripNotification } from "@/src/hooks/useTrip";
@@ -78,30 +78,9 @@ export function TripReportPanel({ tripId }: TripReportPanelProps) {
إنشاء تقرير الرحلة إنشاء تقرير الرحلة
</p> </p>
<button <Button type="button" onClick={handleGenerate} loading={loading}>
type="button"
onClick={handleGenerate}
disabled={loading}
style={{
height: 38,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "none",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
background: loading ? "var(--color-brand-400)" : "var(--color-brand-600)",
cursor: loading ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
whiteSpace: "nowrap",
}}
>
{loading && <Spinner size="sm" className="text-white" />}
{loading ? "جارٍ الإنشاء…" : "إنشاء تقرير البيان"} {loading ? "جارٍ الإنشاء…" : "إنشاء تقرير البيان"}
</button> </Button>
</div> </div>
<Toast <Toast

View 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>
)}
</>
);
}

View File

@@ -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>
); );

View 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>
);
}

View File

@@ -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>

View File

@@ -0,0 +1,22 @@
// ── Icon button ───────────────────────────────────────────────────────────────
// Kept custom rather than swapped for <Button/>: Button's variants only cover
// primary/secondary/ghost/danger (one accent color each) and its size scale
// (h-8/h-10/h-11 with horizontal padding) isn't built for a fixed 32×32
// square icon chip. Forcing it here would misshape the button and collapse
// the view/edit/delete green/blue/red color-coding into one variant.
export function IconBtn({ onClick, title, color, bg, borderColor, children }: {
onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode;
}) {
return (
<button type="button" title={title} aria-label={title}
onClick={e => { e.stopPropagation(); onClick(); }}
style={{
width: 32, height: 32, borderRadius: "var(--radius-md)",
border: `1px solid ${borderColor}`, background: bg, color,
cursor: "pointer", display: "inline-flex", alignItems: "center",
justifyContent: "center", transition: "opacity 150ms",
}}>
{children}
</button>
);
}

View File

@@ -1,5 +1,6 @@
// Accessible labelled input with error and hint state. // Accessible labelled input with error and hint state.
import { forwardRef } from "react";
import { cn } from "@/src/lib/utils"; import { cn } from "@/src/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
@@ -29,7 +30,10 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
* hint="At least 8 characters" * hint="At least 8 characters"
* /> * />
*/ */
export function Input({ label, error, hint, id, icon, className, ...rest }: InputProps) { export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ label, error, hint, id, icon, className, ...rest },
ref,
) {
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, "-")}`; const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, "-")}`;
const errorId = `${inputId}-error`; const errorId = `${inputId}-error`;
const hintId = `${inputId}-hint`; const hintId = `${inputId}-hint`;
@@ -54,6 +58,7 @@ export function Input({ label, error, hint, id, icon, className, ...rest }: Inpu
)} )}
<input <input
ref={ref}
id={inputId} id={inputId}
aria-invalid={!!error} aria-invalid={!!error}
aria-describedby={ aria-describedby={
@@ -88,4 +93,4 @@ export function Input({ label, error, hint, id, icon, className, ...rest }: Inpu
)} )}
</div> </div>
); );
} });

View File

@@ -16,6 +16,8 @@ interface ModalProps {
subtitle?: string; subtitle?: string;
/** ARIA role for the dialog panel — "alertdialog" for destructive/confirm flows */ /** ARIA role for the dialog panel — "alertdialog" for destructive/confirm flows */
role?: "dialog" | "alertdialog"; role?: "dialog" | "alertdialog";
/** Stacking order — bump this for a modal nested inside another modal (default 50) */
zIndex?: number;
} }
const MODAL_SIZES: Record<NonNullable<ModalProps["size"]>, string> = { const MODAL_SIZES: Record<NonNullable<ModalProps["size"]>, string> = {
@@ -33,6 +35,7 @@ export function Modal({
size = "md", size = "md",
subtitle, subtitle,
role = "dialog", role = "dialog",
zIndex = 50,
}: ModalProps) { }: ModalProps) {
// Escape key to close // Escape key to close
useEffect(() => { useEffect(() => {
@@ -47,7 +50,7 @@ export function Modal({
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 flex items-center justify-center p-4" style={{ zIndex }}>
{/* Backdrop */} {/* Backdrop */}
<div <div
className="absolute inset-0 bg-[var(--color-slate-900)]/50 backdrop-blur-sm" className="absolute inset-0 bg-[var(--color-slate-900)]/50 backdrop-blur-sm"

View 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>
);
}

View 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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -24,3 +24,8 @@ export { EmptyState } from "./EmptyState";
export { Badge } from "./Badge"; export { Badge } from "./Badge";
export { ArchiveButton } from "./ArchiveButton" export { ArchiveButton } from "./ArchiveButton"
export { FileInput } from "./FailInput"
export {IconBtn} from "./IconBtn"
export { ReusableTable } from "./ReusableTable";
export { ActionButtons } from "./ActionButtons";
export { ModelCard } from "./ModelCard";

Some files were not shown because too many files have changed in this diff Show More