Compare commits

...

12 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
126 changed files with 9412 additions and 4793 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

@@ -288,17 +288,7 @@ export function Sidebar() {
color: "#fff", color: "#fff",
}} }}
> >
<svg <i className="ti ti-x" style={{ fontSize: 13 }} aria-hidden="true" />
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button> </button>
<SidebarContent <SidebarContent
@@ -335,7 +325,7 @@ function SidebarContent({
justifyContent: compact ? "center" : "flex-start", justifyContent: compact ? "center" : "flex-start",
}} }}
> >
{compact ? <BrandIcon size={36} /> : <Logo white />} {compact ? <BrandIcon size={36} /> : <Logo white href="/dashboard" />}
</div> </div>
<nav <nav

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)",
@@ -417,4 +806,4 @@ export default function CarsPage() {
<ArchiveButton onClick={() => setArchiveOpen(true)} /> <ArchiveButton onClick={() => setArchiveOpen(true)} />
</> </>
); );
} }

View File

@@ -1,100 +1,339 @@
// app/dashboard/[clientId]/addresses/[addressesId]/page.tsx
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Alert, Spinner, Button } from "@/src/Components/UI"; import { Alert, ConfirmDialog, Toast } from "@/src/Components/UI";
import { AddressDetails } from "@/src/Components/Client_Adress/AddressDetails"; import { AddressFormModal } from "@/src/Components/Client";
import { clientAddressService } from "@/src/services/clientAddress.service"; import { AddressDetails } from "@/src/Components/Client_Adress/AddressDetails";
import { getStoredToken } from "@/src/lib/auth"; import { clientAddressService } from "@/src/services/clientAddress.service";
import type { ClientAddress } from "@/src/types/client_adresses"; import { clientService } from "@/src/services/client.service";
import { getStoredToken } from "@/src/lib/auth";
export default function AddressDetailsPage() { import type { Client } from "@/src/types/client";
const params = useParams(); import type { ClientAddress } from "@/src/types/client_adresses";
const router = useRouter(); import type { ToastNotification } from "@/src/Components/UI";
import type {
CreateAddressFormValues,
UpdateAddressFormValues,
} from "@/src/validations/client_address.validator";
const clientId = params?.clientId as string | undefined; // ─── Page ──────────────────────────────────────────────────────────────────
const addressId = params?.addressesId as string | undefined;
export default function AddressDetailPage() {
const params = useParams();
const router = useRouter();
const clientId = (params?.clientId ?? params?.id) as string | undefined;
const addressId = params?.addressId as string | undefined;
// ── Data state ───────────────────────────────────────────────────────────
const [address, setAddress] = useState<ClientAddress | null>(null); const [address, setAddress] = useState<ClientAddress | null>(null);
const [client, setClient] = useState<Client | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadAddress = useCallback(async () => { // ── Modal state ──────────────────────────────────────────────────────────
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
// ── Toast ────────────────────────────────────────────────────────────────
const [notification, setNotification] = useState<ToastNotification | null>(null);
const notify = (n: ToastNotification) => {
setNotification(n);
setTimeout(() => setNotification(null), 4000);
};
// ── Fetch ────────────────────────────────────────────────────────────────
useEffect(() => {
if (!clientId || !addressId) return; if (!clientId || !addressId) return;
setLoading(true); setLoading(true);
setError(null);
try { Promise.all([
const token = getStoredToken(); clientAddressService.getById(clientId, addressId, getStoredToken()),
const res = await clientAddressService.getById(clientId, addressId, token); clientService.getById(clientId, getStoredToken()),
// normalize in case the API returns _id instead of id ])
const raw = res.data as ClientAddress & { _id?: string }; .then(([addrRes, clientRes]) => {
setAddress({ ...raw, id: raw.id ?? raw._id ?? "" }); const raw = (addrRes as any).data ?? addrRes;
} catch { setAddress({ ...raw, id: raw.id ?? raw._id });
setError("تعذّر تحميل بيانات العنوان. يرجى المحاولة لاحقاً."); setClient(clientRes?.data);
} finally { })
setLoading(false); .catch(() => setError("تعذّر تحميل بيانات العنوان. يرجى المحاولة مجدداً."))
} .finally(() => setLoading(false));
}, [clientId, addressId]); }, [clientId, addressId]);
useEffect(() => { // ── Update ───────────────────────────────────────────────────────────────
loadAddress(); const handleUpdateSubmit = async (
}, [loadAddress]); data: CreateAddressFormValues | UpdateAddressFormValues
): Promise<boolean> => {
if (!clientId || !addressId) return false;
try {
const res = await clientAddressService.update(
clientId,
addressId,
data as UpdateAddressFormValues,
getStoredToken()
);
const raw = (res as any).data ?? res;
setAddress({ ...raw, id: raw.id ?? raw._id });
notify({ type: "success", message: "تم تحديث العنوان بنجاح." });
return true;
} catch (err) {
notify({
type: "error",
message: err instanceof Error ? err.message : "تعذّر تحديث العنوان.",
});
return false;
}
};
// ── Loading ────────────────────────────────────────────────────────────── // ── Delete ───────────────────────────────────────────────────────────────
const handleDeleteConfirm = async () => {
if (!clientId || !addressId || deleting) return;
setDeleting(true);
try {
await clientAddressService.delete(clientId, addressId, getStoredToken());
notify({ type: "success", message: "تم حذف العنوان بنجاح." });
// short delay so the toast shows before navigation
setTimeout(() => {
router.replace(`/dashboard/clients/${clientId}/addresses`);
}, 700);
} catch (err) {
notify({
type: "error",
message: err instanceof Error ? err.message : "تعذّر حذف العنوان.",
});
setDeleting(false);
}
};
// ── Guards ───────────────────────────────────────────────────────────────
if (loading) { if (loading) {
return ( return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "60vh", gap: 12 }}> <div
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>جارٍ تحميل بيانات العنوان</span>
</div>
);
}
// ── Error ────────────────────────────────────────────────────────────────
if (error || !address) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem", maxWidth: 640, margin: "2rem auto" }}>
<Alert type="error" message={error ?? "العنوان غير موجود."} />
<Button
type="button"
variant="secondary"
onClick={() => router.push(`/dashboard/${clientId}/addresses`)}
>
العودة إلى العناوين
</Button>
</div>
);
}
// ── Content ──────────────────────────────────────────────────────────────
return (
<section style={{ display: "flex", flexDirection: "column", gap: "1.25rem", maxWidth: 720, margin: "0 auto", padding: "2rem 1rem" }}>
<button
type="button"
onClick={() => router.push(`/dashboard/${clientId}/addresses`)}
style={{ style={{
display: "inline-flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 6, justifyContent: "center",
fontSize: 12, minHeight: "60vh",
fontWeight: 600,
color: "var(--color-text-muted)",
background: "none",
border: "none",
cursor: "pointer",
alignSelf: "flex-start",
fontFamily: "var(--font-sans)",
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <div
<path d="M15 18l-6-6 6-6" /> style={{
</svg> width: 36,
كل العناوين height: 36,
</button> borderRadius: "50%",
border: "3px solid var(--color-border)",
borderTopColor: "var(--color-brand-600)",
animation: "spin 0.7s linear infinite",
}}
/>
</div>
);
}
<AddressDetails address={address} /> if (error || !address) {
</section> return (
<section style={{ padding: "2rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
<Alert type="error" message={error ?? "لم يتم العثور على العنوان."} />
<button
type="button"
onClick={() => router.back()}
style={{
alignSelf: "flex-start",
fontSize: 13,
fontWeight: 600,
color: "var(--color-brand-600)",
background: "none",
border: "none",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}}
>
رجوع
</button>
</section>
);
}
// ── Render ────────────────────────────────────────────────────────────────
return (
<>
<Toast notification={notification} />
{/* Edit modal */}
{editOpen && (
<AddressFormModal
editAddress={address}
onClose={() => setEditOpen(false)}
onSubmit={handleUpdateSubmit}
/>
)}
{/* Delete confirmation */}
<ConfirmDialog
open={deleteOpen}
loading={deleting}
onCancel={() => { if (!deleting) setDeleteOpen(false); }}
onConfirm={handleDeleteConfirm}
title="حذف العميل"
description={`هل أنت متأكد من حذف ${address.label}؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.`}
/>
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Page header ── */}
<header
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
padding: "1.5rem 2rem",
boxShadow: "var(--shadow-card)",
}}
>
{/* Back link */}
<button
type="button"
onClick={() => router.push(`/dashboard/clients/${clientId}/addresses`)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-muted)",
background: "none",
border: "none",
cursor: "pointer",
marginBottom: 12,
fontFamily: "var(--font-sans)",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 18l-6-6 6-6" />
</svg>
{client ? `${client.name} — العناوين` : "العناوين"}
</button>
<p
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
margin: 0,
}}
>
تفاصيل العنوان
</p>
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
{address.label}
</h1>
{client && (
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
{client.name}
</p>
)}
</div>
{/* Action buttons */}
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
type="button"
onClick={() => setEditOpen(true)}
style={{
height: 40,
padding: "0 1rem",
borderRadius: "var(--radius-lg)",
border: "1px solid #BFDBFE",
background: "#EFF6FF",
fontSize: 13,
fontWeight: 600,
color: "#1D4ED8",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 7,
fontFamily: "var(--font-sans)",
}}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
تعديل العنوان
</button>
<button
type="button"
onClick={() => setDeleteOpen(true)}
style={{
height: 40,
padding: "0 1rem",
borderRadius: "var(--radius-lg)",
border: "1px solid #FECACA",
background: "#FEF2F2",
fontSize: 13,
fontWeight: 600,
color: "#DC2626",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 7,
fontFamily: "var(--font-sans)",
}}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4h6v2" />
</svg>
حذف
</button>
</div>
</div>
</header>
{/* ── Address details component ── */}
<AddressDetails address={address} />
</section>
</>
); );
} }

View File

@@ -8,16 +8,21 @@ import {
ConfirmDialog, ConfirmDialog,
Toast, Toast,
ArchiveButton, ArchiveButton,
Button,
EmptyState,
PageLoader,
InlineLoader,
} from "@/src/Components/UI"; } from "@/src/Components/UI";
import { useClientAddresses } from "@/src/hooks/useClientAddresses"; import { useClientAddresses } from "@/src/hooks/useClientAddresses";
import { clientService } from "@/src/services/client.service"; import { clientService } from "@/src/services/client.service";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import type { Client, ClientFormData } from "@/src/types/client"; import type { Client, ClientFormData } from "@/src/types/client";
import type { ClientAddress } from "@/src/types/client_adresses"; import type { ClientAddress } from "@/src/types/client_adresses";
import { AddressFormModal, ClientFormModal } from "@/src/Components/Client"; import { AddressFormModal, ClientFormModal } from "@/src/Components/Client";
import { AddressDetailModal } from "@/src/Components/Client_Adress/AddressDetailModal";
import { ArchivedClientsAddressesModal } from "@/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal"; import { ArchivedClientsAddressesModal } from "@/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal";
import type { import type {
CreateAddressFormValues, CreateAddressFormValues,
@@ -28,38 +33,36 @@ import type {
function labelIcon(label: string): string { function labelIcon(label: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
فوترة: "💳", "فوترة": "💳",
شحن: "📦", "شحن": "📦",
"المقر الرئيسي": "🏢", "المقر الرئيسي": "🏢",
فرع: "🏬", "فرع": "🏬",
مستودع: "🏭", "مستودع": "🏭",
billing: "💳", billing: "💳",
shipping: "📦", shipping: "📦",
"head office": "🏢", "head office": "🏢",
branch: "🏬", branch: "🏬",
warehouse: "🏭", warehouse: "🏭",
}; };
return map[label.toLowerCase()] ?? "📍"; return map[label.toLowerCase()] ?? "📍";
} }
// ─── AddressCard ─────────────────────────────────────────────────────────── // ─── AddressCard ───────────────────────────────────────────────────────────
// CHANGE: clicking the card now opens AddressDetailModal — same pattern as
// UserTable → UserDetailModal (viewUserId). Action buttons stop propagation
// so they don't trigger the modal. `ActionBtn` (custom component) removed in
// favor of the shared <Button size="sm" /> from the template.
interface AddressCardProps { interface AddressCardProps {
address: ClientAddress; address: ClientAddress;
onView: () => void; onView: () => void;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onSetPrimary: () => void; onSetPrimary: () => void;
settingPrimary: boolean; // true only while THIS card's request is in flight settingPrimary: boolean; // true only while THIS card's request is in flight
} }
function AddressCard({ function AddressCard({ address, onView, onEdit, onDelete, onSetPrimary, settingPrimary }: AddressCardProps) {
address,
onEdit,
onDelete,
onSetPrimary,
settingPrimary,
}: AddressCardProps) {
const { details, contactPerson } = address; const { details, contactPerson } = address;
const { const {
street, street,
@@ -75,6 +78,10 @@ function AddressCard({
return ( return (
<div <div
onClick={onView}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onView(); }}
style={{ style={{
position: "relative", position: "relative",
display: "flex", display: "flex",
@@ -85,7 +92,7 @@ function AddressCard({
padding: "1.25rem", padding: "1.25rem",
background: "var(--color-surface)", background: "var(--color-surface)",
boxShadow: "var(--shadow-card)", boxShadow: "var(--shadow-card)",
cursor: "pointer", cursor: "pointer",
}} }}
> >
{/* Label row */} {/* Label row */}
@@ -149,13 +156,7 @@ function AddressCard({
gap: 2, gap: 2,
}} }}
> >
<p <p style={{ margin: 0, fontWeight: 600, color: "var(--color-text-primary)" }}>
style={{
margin: 0,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
{street} {street}
</p> </p>
@@ -168,14 +169,7 @@ function AddressCard({
</p> </p>
{(buildingNo || unitNo || additionalNo) && ( {(buildingNo || unitNo || additionalNo) && (
<p <p style={{ margin: 0, fontSize: 12, color: "var(--color-text-muted)" }} dir="ltr">
style={{
margin: 0,
fontSize: 12,
color: "var(--color-text-muted)",
}}
dir="ltr"
>
{buildingNo && `مبنى ${buildingNo}`} {buildingNo && `مبنى ${buildingNo}`}
{unitNo && ` · وحدة ${unitNo}`} {unitNo && ` · وحدة ${unitNo}`}
{additionalNo && ` · رقم إضافي ${additionalNo}`} {additionalNo && ` · رقم إضافي ${additionalNo}`}
@@ -209,9 +203,9 @@ function AddressCard({
</div> </div>
)} )}
{/* Actions */} {/* Actions — stopPropagation so clicking them doesn't open the detail modal */}
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
@@ -221,131 +215,31 @@ function AddressCard({
borderTop: "1px solid var(--color-border)", borderTop: "1px solid var(--color-border)",
}} }}
> >
<ActionBtn <Button type="button" variant="secondary" size="sm" onClick={onEdit}>
onClick={onEdit}
color="#1D4ED8"
bg="#EFF6FF"
border="#BFDBFE"
>
تعديل تعديل
</ActionBtn> </Button>
{!address.isPrimary && ( {!address.isPrimary && (
<ActionBtn <Button
type="button"
variant="secondary"
size="sm"
onClick={onSetPrimary} onClick={onSetPrimary}
disabled={settingPrimary} loading={settingPrimary}
color="#B45309"
bg="#FFFBEB"
border="#FDE68A"
> >
{settingPrimary ? "جارٍ التعيين…" : "تعيين كأساسي"} تعيين كأساسي
</ActionBtn> </Button>
)} )}
<ActionBtn <Button
type="button"
variant="danger"
size="sm"
onClick={onDelete} onClick={onDelete}
color="#DC2626" style={{ marginInlineStart: "auto" }}
bg="#FEF2F2"
border="#FECACA"
style={{ marginRight: "auto" }}
> >
حذف حذف
</ActionBtn> </Button>
</div>
</div>
);
}
function ActionBtn({
onClick,
color,
bg,
border,
style,
disabled,
children,
}: {
onClick: () => void;
color: string;
bg: string;
border: string;
style?: React.CSSProperties;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
style={{
height: 30,
padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${border}`,
background: bg,
fontSize: 12,
fontWeight: 600,
color,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
fontFamily: "var(--font-sans)",
transition: "opacity 150ms",
...style,
}}
>
{children}
</button>
);
}
// ─── ClientEditModal ───────────────────────────────────────────────────────
interface ClientEditModalProps {
client: Client;
onClose: () => void;
onSubmit: (data: ClientFormData, isNew: boolean) => Promise<boolean>;
}
function ClientEditModal({ client, onClose, onSubmit }: ClientEditModalProps) {
return (
<div
role="dialog"
aria-modal="true"
aria-label="تعديل بيانات العميل"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
background: "rgba(15,23,42,0.55)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
padding: "2rem 1rem",
overflowY: "auto",
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: 520,
background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
}}
>
<ClientFormModal
editClient={client}
onClose={onClose}
onSubmit={onSubmit}
/>
</div> </div>
</div> </div>
); );
@@ -354,8 +248,8 @@ function ClientEditModal({ client, onClose, onSubmit }: ClientEditModalProps) {
// ─── Page ────────────────────────────────────────────────────────────────── // ─── Page ──────────────────────────────────────────────────────────────────
export default function ClientAddressesPage() { export default function ClientAddressesPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const clientId = (params?.clientId ?? params?.id) as string | undefined; const clientId = (params?.clientId ?? params?.id) as string | undefined;
@@ -366,7 +260,7 @@ export default function ClientAddressesPage() {
}, [clientId]); }, [clientId]);
// ── Parent client ──────────────────────────────────────────────────────── // ── Parent client ────────────────────────────────────────────────────────
const [client, setClient] = useState<Client | null>(null); const [client, setClient] = useState<Client | null>(null);
const [clientLoading, setClientLoading] = useState(true); const [clientLoading, setClientLoading] = useState(true);
const loadClient = useCallback(() => { const loadClient = useCallback(() => {
@@ -400,21 +294,20 @@ export default function ClientAddressesPage() {
} = useClientAddresses(clientId ?? ""); } = useClientAddresses(clientId ?? "");
// ── Modal state ────────────────────────────────────────────────────────── // ── Modal state ──────────────────────────────────────────────────────────
const [addrFormTarget, setAddrFormTarget] = useState< const [addrFormTarget, setAddrFormTarget] = useState<ClientAddress | null | false>(false);
ClientAddress | null | false const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null);
>(false); const [deleting, setDeleting] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null); const [editingClient, setEditingClient] = useState(false);
const [deleting, setDeleting] = useState(false);
const [editingClient, setEditingClient] = useState(false);
// Archive browser modal open/closed — scoped to this client's addresses // Archive browser modal open/closed — scoped to this client's addresses
const [archiveOpen, setArchiveOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false);
// Address whose detail modal is open — same pattern as viewUserId/UserDetailModal
const [viewAddress, setViewAddress] = useState<ClientAddress | null>(null);
// ── Address form handler ───────────────────────────────────────────────── // ── Address form handler ─────────────────────────────────────────────────
const handleAddressSubmit = async ( const handleAddressSubmit = async (
data: CreateAddressFormValues | UpdateAddressFormValues, data: CreateAddressFormValues | UpdateAddressFormValues
): Promise<boolean> => { ): Promise<boolean> => {
if (addrFormTarget === null) if (addrFormTarget === null) return createAddress(data as CreateAddressFormValues);
return createAddress(data as CreateAddressFormValues);
if (addrFormTarget === false) return false; if (addrFormTarget === false) return false;
return updateAddress(addrFormTarget.id, data as UpdateAddressFormValues); return updateAddress(addrFormTarget.id, data as UpdateAddressFormValues);
}; };
@@ -448,28 +341,9 @@ export default function ClientAddressesPage() {
}; };
// ── Loading guard ──────────────────────────────────────────────────────── // ── Loading guard ────────────────────────────────────────────────────────
// CHANGE: swapped the hand-rolled spinning <div> for the template's PageLoader.
if (!clientId || clientLoading) { if (!clientId || clientLoading) {
return ( return <PageLoader message="جارٍ تحميل بيانات العميل…" />;
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: "50%",
border: "3px solid var(--color-border)",
borderTopColor: "var(--color-brand-600)",
animation: "spin 0.7s linear infinite",
}}
/>
</div>
);
} }
// ── Render ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
@@ -477,6 +351,14 @@ export default function ClientAddressesPage() {
<> <>
<Toast notification={notification} /> <Toast notification={notification} />
{/* Address detail modal — same pattern as viewUserId → UserDetailModal */}
{viewAddress && (
<AddressDetailModal
address={viewAddress}
onClose={() => setViewAddress(null)}
/>
)}
{/* Address create / edit modal */} {/* Address create / edit modal */}
{addrFormTarget !== false && ( {addrFormTarget !== false && (
<AddressFormModal <AddressFormModal
@@ -490,18 +372,22 @@ export default function ClientAddressesPage() {
<ConfirmDialog <ConfirmDialog
open={!!deleteTarget} open={!!deleteTarget}
loading={deleting} loading={deleting}
onCancel={() => { onCancel={() => { if (!deleting) setDeleteTarget(null); }}
if (!deleting) setDeleteTarget(null);
}}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
title="حذف العميل" title="حذف العنوان"
description={`هل أنت متأكد من حذف ${deleteTarget?.label ?? ""}؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.`} description={`هل أنت متأكد من حذف ${deleteTarget?.label ?? ""}؟ لا يمكن التراجع عن هذا الإجراء.`}
/> />
{/* Client edit modal */} {/*
Client edit modal
CHANGE: removed the custom `ClientEditModal` wrapper that built its
own fixed-position backdrop by hand. `ClientFormModal` already
renders the shared <Modal/> internally, so wrapping it in another
backdrop produced two stacked overlays. We now render it directly.
*/}
{editingClient && client && ( {editingClient && client && (
<ClientEditModal <ClientFormModal
client={client} editClient={client}
onClose={() => setEditingClient(false)} onClose={() => setEditingClient(false)}
onSubmit={handleClientEditSubmit} onSubmit={handleClientEditSubmit}
/> />
@@ -515,9 +401,8 @@ export default function ClientAddressesPage() {
/> />
)} )}
<section <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
>
{/* ── Header ── */} {/* ── Header ── */}
<header <header
style={{ style={{
@@ -528,145 +413,48 @@ export default function ClientAddressesPage() {
boxShadow: "var(--shadow-card)", boxShadow: "var(--shadow-card)",
}} }}
> >
<button {/* CHANGE: custom <button> → Button variant="ghost" */}
<Button
type="button" type="button"
variant="ghost"
size="sm"
onClick={() => router.push("/dashboard/clients")} onClick={() => router.push("/dashboard/clients")}
style={{ style={{ marginBottom: 12 }}
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-muted)",
background: "none",
border: "none",
cursor: "pointer",
marginBottom: 12,
fontFamily: "var(--font-sans)",
}}
> >
<svg <i className="ti ti-chevron-left" style={{ fontSize: 14 }} aria-hidden="true" />
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 18l-6-6 6-6" />
</svg>
جميع العملاء جميع العملاء
</button> </Button>
<p <p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
}}
>
إدارة العناوين إدارة العناوين
</p> </p>
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> <div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<h1 <h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
{client ? `${client.name} — العناوين` : "العناوين"} {client ? `${client.name} — العناوين` : "العناوين"}
</h1> </h1>
<p <p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
style={{
marginTop: "0.25rem",
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
إجمالي{" "} إجمالي{" "}
<strong style={{ color: "var(--color-text-primary)" }}> <strong style={{ color: "var(--color-text-primary)" }}>{addresses.length}</strong>{" "}
{addresses.length}
</strong>{" "}
عنوان عنوان
</p> </p>
</div> </div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{/* CHANGE: custom <button> → Button variant="secondary" */}
{client && ( {client && (
<button <Button type="button" variant="secondary" onClick={() => setEditingClient(true)}>
type="button" <i className="ti ti-edit" style={{ fontSize: 13 }} aria-hidden="true" />
onClick={() => setEditingClient(true)}
style={{
height: 40,
padding: "0 1rem",
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 7,
fontFamily: "var(--font-sans)",
}}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
تعديل بيانات العميل تعديل بيانات العميل
</button> </Button>
)} )}
<button {/* CHANGE: custom <button> → Button variant="primary" */}
type="button" <Button type="button" variant="primary" onClick={() => setAddrFormTarget(null)}>
onClick={() => setAddrFormTarget(null)} <i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
style={{
height: 40,
padding: "0 1.125rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 7,
fontFamily: "var(--font-sans)",
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
whiteSpace: "nowrap",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إضافة عنوان إضافة عنوان
</button> </Button>
</div> </div>
</div> </div>
@@ -685,15 +473,8 @@ export default function ClientAddressesPage() {
borderTop: "1px solid var(--color-border)", borderTop: "1px solid var(--color-border)",
}} }}
> >
<span <span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>{client.name}</span>
style={{ fontWeight: 600, color: "var(--color-text-primary)" }} <a href={`mailto:${client.email}`} style={{ color: "#2563EB", textDecoration: "none" }}>
>
{client.name}
</span>
<a
href={`mailto:${client.email}`}
style={{ color: "#2563EB", textDecoration: "none" }}
>
{client.email} {client.email}
</a> </a>
<span>{client.phone}</span> <span>{client.phone}</span>
@@ -718,79 +499,20 @@ export default function ClientAddressesPage() {
{/* ── Address grid ── */} {/* ── Address grid ── */}
{addrLoading ? ( {addrLoading ? (
<div // CHANGE: hand-rolled spinning <div> → InlineLoader
style={{ <InlineLoader message="جارٍ تحميل العناوين…" />
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "4rem 0",
color: "var(--color-text-muted)",
gap: 12,
fontSize: 13,
}}
>
<div
style={{
width: 20,
height: 20,
borderRadius: "50%",
border: "2px solid var(--color-border)",
borderTopColor: "var(--color-brand-600)",
animation: "spin 0.7s linear infinite",
flexShrink: 0,
}}
/>
جارٍ تحميل العناوين
</div>
) : addresses.length === 0 ? ( ) : addresses.length === 0 ? (
<div // CHANGE: custom empty-state <div> → EmptyState
style={{ <EmptyState
borderRadius: "var(--radius-xl)", icon="📍"
border: "1px solid var(--color-border)", title="لا توجد عناوين بعد"
background: "var(--color-surface)", description="أضف عنواناً واحداً على الأقل حتى يتمكن العميل من استقبال الشحنات والفواتير."
padding: "4rem 1rem", action={
textAlign: "center", <Button type="button" variant="primary" onClick={() => setAddrFormTarget(null)}>
boxShadow: "var(--shadow-card)", أضف أول عنوان
}} </Button>
> }
<p style={{ fontSize: 32, margin: 0 }}>📍</p> />
<p
style={{
marginTop: 12,
fontSize: 15,
fontWeight: 700,
color: "var(--color-text-primary)",
}}
>
لا توجد عناوين بعد
</p>
<p
style={{
marginTop: 4,
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
أضف عنواناً واحداً على الأقل حتى يتمكن العميل من استقبال الشحنات
والفواتير.
</p>
<button
type="button"
onClick={() => setAddrFormTarget(null)}
style={{
marginTop: 16,
fontSize: 13,
fontWeight: 600,
color: "var(--color-brand-600)",
background: "none",
border: "none",
cursor: "pointer",
textDecoration: "underline",
}}
>
أضف أول عنوان
</button>
</div>
) : ( ) : (
<div <div
style={{ style={{
@@ -799,26 +521,23 @@ export default function ClientAddressesPage() {
gap: "1rem", gap: "1rem",
}} }}
> >
{addresses.map((addr) => ( {addresses.map((addr) => (
<AddressCard <AddressCard
key={addr.id} key={addr.id}
address={addr} address={addr}
onView={() => router.push(`/dashboard/${clientId}/addresses/${addr.id}`)} onView={() => setViewAddress(addr)}
onEdit={() => setAddrFormTarget(addr)} onEdit={() => setAddrFormTarget(addr)}
onDelete={() => setDeleteTarget(addr)} onDelete={() => setDeleteTarget(addr)}
onSetPrimary={() => handleSetPrimary(addr)} onSetPrimary={() => handleSetPrimary(addr)}
settingPrimary={settingPrimaryId === addr.id} settingPrimary={settingPrimaryId === addr.id}
/> />
))} ))}
</div> </div>
)} )}
</section> </section>
{/* Floating button to open the address archive for this client */} {/* Floating button to open the address archive for this client */}
<ArchiveButton <ArchiveButton onClick={() => setArchiveOpen(true)} label="أرشيف العناوين" />
onClick={() => setArchiveOpen(true)}
label="أرشيف العناوين"
/>
</> </>
); );
} }

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)}
@@ -367,4 +646,4 @@ export default function DriverDetailPage() {
/> />
</> </>
); );
} }

View File

@@ -1,74 +1,16 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { import { Alert, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
Alert,
Spinner,
ArchiveButton,
ConfirmDialog,
Toast,
} from "@/src/Components/UI";
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers"; import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
import { DriverTable } from "@/src/Components/Driver/DriverTable";
import { useDrivers } from "@/src/hooks/useDriver"; import { useDrivers } from "@/src/hooks/useDriver";
import { import {
CreateDriverPayload, CreateDriverPayload,
Driver, Driver,
DRIVER_STATUS_MAP,
UpdateDriverPayload, UpdateDriverPayload,
} from "@/src/types/driver"; } from "@/src/types/driver";
import { DriverDetailPanel, DriverFormModal } from "@/src/Components/Driver"; import { DriverDetailPanel, DriverFormModal } from "@/src/Components/Driver";
import Header from "@/src/Components/UI/Header";
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function expirySoon(iso?: string | null): boolean {
if (!iso) return false;
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
}
// ── Styles ────────────────────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem",
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.2em",
color: "var(--color-text-muted)",
background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// Shared across header + rows — keep these in sync or columns will misalign.
const ROW_GRID_COLUMNS = "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr 0.8fr";
const iconBtnBase: React.CSSProperties = {
width: 30,
height: 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-md)",
cursor: "pointer",
flexShrink: 0,
};
// ── Page Component ──────────────────────────────────────────────────────────── // ── Page Component ────────────────────────────────────────────────────────────
@@ -88,7 +30,6 @@ export default function DriversPage() {
updateDriver, updateDriver,
deleteDriver, deleteDriver,
notification, notification,
clearNotification,
} = useDrivers(); } = useDrivers();
// ── Panel / modal state ─────────────────────────────────────────────────── // ── Panel / modal state ───────────────────────────────────────────────────
@@ -161,317 +102,165 @@ export default function DriversPage() {
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
> >
{/* ── Header ── */} {/* ── Header ── */}
<Header <header
mainTitle="إدارة السائقين" style={{
title="السائقون" borderRadius: "var(--radius-xl)",
name="سائق" border: "1px solid var(--color-border)",
state={{ total }} background: "var(--color-surface)",
search={search} padding: "1.5rem 2rem",
setSearch={handleSearch} boxShadow: "var(--shadow-card)",
module="" }}
setModule={() => {}} >
setPage={setPage} <p
isAudit={false} style={{
onAdd={() => setFormDriver("new")} fontSize: 11,
/> letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
}}
>
إدارة الكوادر
</p>
<div
style={{
marginTop: "0.75rem",
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
flexWrap: "wrap",
gap: "1rem",
}}
>
<div>
<h1
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
السائقون
</h1>
<p
style={{
marginTop: "0.25rem",
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
إجمالي{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{total}
</strong>{" "}
سائق مسجل
</p>
</div>
<div
style={{
display: "flex",
gap: "0.75rem",
alignItems: "center",
flexWrap: "wrap",
}}
>
{/* Search */}
<div style={{ position: "relative", width: 288 }}>
<i
className="ti ti-search"
aria-hidden="true"
style={{
position: "absolute",
right: 12,
top: "50%",
transform: "translateY(-50%)",
fontSize: 16,
color: "var(--color-text-hint)",
pointerEvents: "none",
}}
/>
<input
type="text"
placeholder="بحث بالاسم أو الهاتف..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
dir="rtl"
style={{
width: "100%",
height: 40,
paddingRight: 36,
paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
color: "var(--color-text-primary)",
outline: "none",
fontFamily: "var(--font-sans)",
}}
/>
</div>
{/* Add button */}
<button
type="button"
onClick={() => setFormDriver("new")}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
flexShrink: 0,
}}
>
<i
className="ti ti-plus"
style={{ fontSize: 15 }}
aria-hidden="true"
/>
إضافة سائق
</button>
</div>
</div>
</div>
</header>
{/* ── Notifications ── */} {/* ── Notifications ── */}
<Toast notification={notification} onDismiss={clearNotification} /> {notification && (
<Alert type={notification.type} message={notification.message} />
)}
{error && <Alert type="error" message={error} onClose={clearError} />} {error && <Alert type="error" message={error} onClose={clearError} />}
{/* ── Table ── */} {/* ── Table ── */}
<div style={cardStyle}> <DriverTable
<div drivers={drivers}
dir="rtl" loading={loading}
style={{ search={search}
display: "grid", page={page}
gridTemplateColumns: ROW_GRID_COLUMNS, pages={pages}
...thStyle, onPageChange={setPage}
}} onRowClick={(d) => setSelectedDriverId(d.id)}
> onEdit={handleEdit}
<span>السائق</span> onDelete={handleDelete}
<span>الفرع</span> />
<span>الجنسية</span>
<span>انتهاء الرخصة</span>
<span>انتهاء الهوية</span>
<span>الحالة</span>
<span>إجراءات</span>
</div>
{loading ? (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "4rem 0",
color: "var(--color-text-muted)",
}}
>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : drivers.length === 0 ? (
<p
style={{
textAlign: "center",
padding: "4rem 0",
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
لا توجد نتائج {search && `لـ "${search}"`}
</p>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{drivers.map((d, i) => {
const statusCfg =
DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive;
const licWarn = expirySoon(d.licenseExpiry);
const idWarn = expirySoon(
(d as Driver & { nationalIdExpiry?: string })
.nationalIdExpiry,
);
return (
<li
key={d.id}
onClick={() => setSelectedDriverId(d.id)}
style={{
display: "grid",
gridTemplateColumns: ROW_GRID_COLUMNS,
alignItems: "center",
gap: "0.5rem",
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background:
i % 2 !== 0
? "var(--color-surface-muted)"
: "transparent",
fontSize: 13,
cursor: "pointer",
transition: "background 0.15s",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background =
"var(--color-surface-hover, #F8FAFC)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background =
i % 2 !== 0
? "var(--color-surface-muted)"
: "transparent")
}
>
<div>
<p
style={{
fontWeight: 600,
color: "var(--color-text-primary)",
margin: 0,
}}
>
{d.name}
</p>
<p
style={{
marginTop: 2,
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--color-text-muted)",
}}
>
{d.phone}
</p>
</div>
<span style={{ color: "var(--color-text-secondary)" }}>
{d.branch?.name ?? "—"}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{d.nationality ?? "—"}
</span>
<span
style={{
fontSize: 12,
fontWeight: licWarn ? 600 : 400,
color: licWarn
? "#D97706"
: "var(--color-text-secondary)",
}}
>
{licWarn && "⚠ "}
{fmtDate(d.licenseExpiry)}
</span>
<span
style={{
fontSize: 12,
fontWeight: idWarn ? 600 : 400,
color: idWarn
? "#D97706"
: "var(--color-text-secondary)",
}}
>
{idWarn && "⚠ "}
{fmtDate(
(d as Driver & { nationalIdExpiry?: string })
.nationalIdExpiry,
)}
</span>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
borderRadius: "var(--radius-full)",
border: `1px solid ${statusCfg.border}`,
background: statusCfg.bg,
padding: "0.2rem 0.625rem",
fontSize: 11,
fontWeight: 600,
color: statusCfg.color,
width: "fit-content",
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: statusCfg.dot,
flexShrink: 0,
}}
/>
{statusCfg.label}
</span>
{/* ── Inline row actions: edit / delete ──
stopPropagation is required on both buttons so the
click doesn't bubble up to the <li> onClick and
open the detail panel as well. */}
<div style={{ display: "flex", gap: "0.4rem" }}>
<button
type="button"
aria-label="تعديل السائق"
title="تعديل"
onClick={(e) => {
e.stopPropagation();
handleEdit(d);
}}
style={{
...iconBtnBase,
border: "1px solid var(--color-brand-200)",
background: "var(--color-brand-50, #EFF6FF)",
color: "var(--color-brand-600)",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
type="button"
aria-label="حذف السائق"
title="حذف"
onClick={(e) => {
e.stopPropagation();
handleDelete(d);
}}
style={{
...iconBtnBase,
border: "1px solid #FECACA",
background: "#FEF2F2",
color: "#DC2626",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
</div>
</li>
);
})}
</ul>
)}
{/* ── Pagination ── */}
{pages > 1 && (
<div
dir="rtl"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderTop: "1px solid var(--color-border)",
padding: "0.875rem 1.5rem",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{page}
</strong>{" "}
من{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{pages}
</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{
label: "السابق",
action: () => setPage((p) => Math.max(1, p - 1)),
disabled: page === 1,
},
{
label: "التالي",
action: () => setPage((p) => Math.min(pages, p + 1)),
disabled: page === pages,
},
].map((btn) => (
<button
key={btn.label}
onClick={btn.action}
disabled={btn.disabled}
style={{
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
padding: "0.375rem 0.875rem",
fontSize: 12,
color: "var(--color-text-secondary)",
cursor: btn.disabled ? "not-allowed" : "pointer",
opacity: btn.disabled ? 0.4 : 1,
fontFamily: "var(--font-sans)",
}}
>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
</section> </section>
{/* ── Detail panel — key forces re-fetch after successful edit ── */} {/* ── Detail panel — key forces re-fetch after successful edit ── */}
@@ -488,6 +277,11 @@ export default function DriversPage() {
{/* ── Form modal ── */} {/* ── Form modal ── */}
{formDriver !== null && ( {formDriver !== null && (
<DriverFormModal <DriverFormModal
// CHANGE: added key — forces remount on switching between "new" and
// different edit targets, same fix applied to UserFormModal, so
// useForm's defaultValues (roleId/branchId/status/etc.) always match
// the correct driver instead of possibly reusing stale form state.
key={formDriver === "new" ? "new" : formDriver.id}
editDriver={formDriver === "new" ? null : formDriver} editDriver={formDriver === "new" ? null : formDriver}
branches={[]} branches={[]}
onClose={() => setFormDriver(null)} onClose={() => setFormDriver(null)}
@@ -512,4 +306,4 @@ export default function DriversPage() {
<ArchiveButton onClick={() => setArchiveOpen(true)} /> <ArchiveButton onClick={() => setArchiveOpen(true)} />
</> </>
); );
} }

View File

@@ -1,18 +1,17 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { Alert, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI"; import { Alert, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
import { ORDER_STATUS_MAP } from "@/src/Components/Order/OrderDetailModel";
import { OrderTable } from "@/src/Components/Order/OrderTable";
import { ArchivedOrdersModal } from "@/src/Components/Order/archive/ArchivedOrdersModal"; import { ArchivedOrdersModal } from "@/src/Components/Order/archive/ArchivedOrdersModal";
import { useOrders } from "@/src/hooks/useOrder"; import { useOrders } from "@/src/hooks/useOrder";
import type { CreateOrderPayload, Order, UpdateOrderPayload } from "@/src/types/order"; import type { CreateOrderPayload, Order, UpdateOrderPayload } from "@/src/types/order";
import { OrderFormModal, OrderDetailPanel, OrderTable } from "@/src/Components/Order"; import { OrderFormModal , OrderDetailPanel} from "@/src/Components/Order";
import Header from "@/src/Components/UI/Header";
// ── Page Component ─────────────────────────────────────────────────────── // ── Page Component ───────────────────────────────────────────────────────
// Table markup/styles now live in src/Components/Order/orderTable.tsx;
// detail panel in OrderDetailModel.tsx (renamed from OrderDetailBanel.tsx);
// form modal unchanged in OrderFormModal.tsx. This page only wires state
// and handlers between the three.
export default function OrderComponent() { export default function OrderComponent() {
const { const {
@@ -93,18 +92,97 @@ export default function OrderComponent() {
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}> <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Header ── */} {/* ── Header ── */}
<Header mainTitle="إدارة الطلبات" <header style={{
title="الطلبات" borderRadius: "var(--radius-xl)",
name="طلب" border: "1px solid var(--color-border)",
state={{ total }} background: "var(--color-surface)",
search={search} padding: "1.5rem 2rem",
setSearch={handleSearch} boxShadow: "var(--shadow-card)",
module="" }}>
setModule={() => {}} <p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
setPage={setPage} إدارة الشحنات
isAudit={false} </p>
onAdd={() => setFormOrder("new")}/> <div style={{ marginTop: "0.75rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
<div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
الطلبات
</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إجمالي{" "}
<strong style={{ color: "var(--color-text-primary)" }}>{total}</strong>{" "}
طلب مسجل
</p>
</div>
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center", flexWrap: "wrap" }}>
{/* Status filter — ported from app/dashboard/orders/page.tsx */}
<select
value={statusFilter}
onChange={(e) => handleStatusFilter(e.target.value)}
dir="rtl"
style={{
height: 40, borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-secondary)",
padding: "0 0.75rem", outline: "none",
fontFamily: "var(--font-sans)", cursor: "pointer",
}}
>
<option value="">كل الحالات</option>
{Object.entries(ORDER_STATUS_MAP).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
{/* Search */}
<div style={{ position: "relative", width: 288 }}>
<i
className="ti ti-search"
aria-hidden="true"
style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", fontSize: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
/>
<input
type="text"
placeholder="رقم الشحنة أو المستلم..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
dir="rtl"
style={{
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)",
outline: "none", fontFamily: "var(--font-sans)",
}}
/>
</div>
{/* Add button */}
<button
type="button"
onClick={() => setFormOrder("new")}
style={{
height: 40, padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: "pointer",
display: "flex", alignItems: "center", gap: 8,
fontFamily: "var(--font-sans)",
flexShrink: 0,
}}
>
<i className="ti ti-plus" style={{ fontSize: 15 }} aria-hidden="true" />
إضافة طلب
</button>
</div>
</div>
</div>
</header>
{/* ── Notifications: page-level error banner (e.g. failed list load) {/* ── Notifications: page-level error banner (e.g. failed list load)
dismissible via clearError() from the hook. Action-result dismissible via clearError() from the hook. Action-result
@@ -124,7 +202,7 @@ export default function OrderComponent() {
page={page} page={page}
pages={pages} pages={pages}
setPage={setPage} setPage={setPage}
onRowClick={setSelectedOrderId} onRowClick={(orderId) => setSelectedOrderId(orderId)}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
/> />
@@ -183,5 +261,6 @@ export default function OrderComponent() {
} }
// Named export alongside the default, mirroring src/Components/Driver/index.ts's // Named export alongside the default, mirroring src/Components/Driver/index.ts's
// pattern of re-exporting each piece for use elsewhere. // pattern of re-exporting each piece for use elsewhere (e.g. a future
// app/dashboard/orders/page.tsx importing { OrderComponent }).
export { OrderComponent }; export { OrderComponent };

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

@@ -1,22 +1,17 @@
"use client"; "use client";
// app/dashboard/trips/[tripId]/page.tsx
// CHANGE: TripFormModal now imported from the barrel (@/src/Components/Trip)
// instead of a direct path — avoids the Tripformmodal.tsx case-sensitivity
// mismatch that breaks on case-sensitive filesystems (Linux/Docker builds).
// CHANGE: loadTrip's catch block now uses the same extractApiMessage helper
// already defined in this file (and used by handleEditSubmit/handleConfirmDelete)
// instead of `err instanceof Error ? err.message : ...` — so backend
// validation messages surface correctly here too, not just on edit/delete.
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { ConfirmDialog, Spinner } from "@/src/Components/UI"; import { Button, ConfirmDialog, Spinner } from "@/src/Components/UI";
import { TripFormModal } from "@/src/Components/Trip"; import { TripFormModal } from "@/src/Components/Trip";
import { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel"; import { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel";
import { tripService } from "@/src/services/trip.service"; import { tripService } from "@/src/services/trip.service";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import type { Trip, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip"; import type {
Trip,
CreateTripPayload,
UpdateTripPayload,
} from "@/src/types/trip";
import { TRIP_STATUS_MAP } from "@/src/types/trip"; import { TRIP_STATUS_MAP } from "@/src/types/trip";
import { Toast, type ToastNotification } from "@/src/Components/UI/Toast"; import { Toast, type ToastNotification } from "@/src/Components/UI/Toast";
@@ -29,14 +24,20 @@ function extractApiMessage(err: unknown, fallback: string): string {
if (err && typeof err === "object") { if (err && typeof err === "object") {
const e = err as Record<string, unknown>; const e = err as Record<string, unknown>;
const responseData = (e["response"] as Record<string, unknown> | undefined)?.["data"]; const responseData = (
e["response"] as Record<string, unknown> | undefined
)?.["data"];
if (responseData && typeof responseData === "object") { if (responseData && typeof responseData === "object") {
const rd = responseData as Record<string, unknown>; const rd = responseData as Record<string, unknown>;
if (typeof rd["message"] === "string" && rd["message"].trim()) return rd["message"]; if (typeof rd["message"] === "string" && rd["message"].trim())
if (Array.isArray(rd["message"])) return (rd["message"] as string[]).join(" — "); return rd["message"];
if (typeof rd["error"] === "string" && rd["error"].trim()) return rd["error"]; if (Array.isArray(rd["message"]))
return (rd["message"] as string[]).join(" — ");
if (typeof rd["error"] === "string" && rd["error"].trim())
return rd["error"];
} }
if (typeof e["message"] === "string" && e["message"].trim()) return e["message"]; if (typeof e["message"] === "string" && e["message"].trim())
return e["message"];
} }
return fallback; return fallback;
@@ -126,7 +127,13 @@ function DetailRow({
borderBottom: "1px solid var(--color-border)", borderBottom: "1px solid var(--color-border)",
}} }}
> >
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}> <span
style={{
fontSize: 12,
color: "var(--color-text-muted)",
fontWeight: 600,
}}
>
{label} {label}
</span> </span>
<span <span
@@ -169,10 +176,24 @@ function StatCard({
gap: 4, gap: 4,
}} }}
> >
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)", letterSpacing: "0.05em" }}> <span
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--color-text-muted)",
letterSpacing: "0.05em",
}}
>
{label} {label}
</span> </span>
<span style={{ fontSize: 22, fontWeight: 800, color, fontFamily: "var(--font-mono)" }}> <span
style={{
fontSize: 22,
fontWeight: 800,
color,
fontFamily: "var(--font-mono)",
}}
>
{value} {value}
</span> </span>
</div> </div>
@@ -186,17 +207,19 @@ export default function TripDetailPage() {
const router = useRouter(); const router = useRouter();
const tripId = params?.tripId as string; const tripId = params?.tripId as string;
const [trip, setTrip] = useState<Trip | null>(null); const [trip, setTrip] = useState<Trip | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// ── Modal state ─────────────────────────────────────────────────────────── // ── Modal state ───────────────────────────────────────────────────────────
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
// ── Notifications (standalone — no longer borrowed from the list hook) ─── // ── Notifications (standalone — no longer borrowed from the list hook) ───
const [notification, setNotification] = useState<ToastNotification | null>(null); const [notification, setNotification] = useState<ToastNotification | null>(
null,
);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const notify = useCallback((n: ToastNotification) => { const notify = useCallback((n: ToastNotification) => {
@@ -210,7 +233,12 @@ export default function TripDetailPage() {
setNotification(null); setNotification(null);
}, []); }, []);
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []); useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
},
[],
);
// ── Load trip ───────────────────────────────────────────────────────────── // ── Load trip ─────────────────────────────────────────────────────────────
const loadTrip = useCallback(async () => { const loadTrip = useCallback(async () => {
@@ -239,12 +267,19 @@ export default function TripDetailPage() {
if (!trip) return false; if (!trip) return false;
try { try {
const token = getStoredToken(); const token = getStoredToken();
const updated = await tripService.update(trip.id, payload as UpdateTripPayload, token); const updated = await tripService.update(
trip.id,
payload as UpdateTripPayload,
token,
);
setTrip(updated); setTrip(updated);
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." }); notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
return true; return true;
} catch (err) { } catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") }); notify({
type: "error",
message: extractApiMessage(err, "تعذّر تحديث الرحلة."),
});
return false; return false;
} }
}, },
@@ -260,7 +295,10 @@ export default function TripDetailPage() {
await tripService.delete(trip.id, token); await tripService.delete(trip.id, token);
router.push("/dashboard/trips"); router.push("/dashboard/trips");
} catch (err) { } catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر حذف الرحلة.") }); notify({
type: "error",
message: extractApiMessage(err, "تعذّر حذف الرحلة."),
});
setDeleting(false); setDeleting(false);
} }
}, [trip, router, notify]); }, [trip, router, notify]);
@@ -304,25 +342,14 @@ export default function TripDetailPage() {
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}> <p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}>
{error ?? "الرحلة غير موجودة"} {error ?? "الرحلة غير موجودة"}
</p> </p>
<button <Button
type="button" variant="secondary"
size="sm"
onClick={() => router.back()} onClick={() => router.back()}
style={{ style={{ marginTop: "1rem" }}
marginTop: "1rem",
height: 38,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}}
> >
رجوع رجوع
</button> </Button>
</div> </div>
); );
} }
@@ -333,8 +360,9 @@ export default function TripDetailPage() {
{/* ── Toast notification ── */} {/* ── Toast notification ── */}
<Toast notification={notification} onDismiss={dismissNotification} /> <Toast notification={notification} onDismiss={dismissNotification} />
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}> <section
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
>
{/* ── Page header ── */} {/* ── Page header ── */}
<header <header
style={{ style={{
@@ -346,31 +374,34 @@ export default function TripDetailPage() {
}} }}
> >
{/* Back link */} {/* Back link */}
<button <Button
type="button" variant="ghost"
size="sm"
onClick={() => router.back()} onClick={() => router.back()}
style={{ style={{
display: "inline-flex", height: "auto",
alignItems: "center",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-muted)",
background: "none",
border: "none",
cursor: "pointer",
padding: 0, padding: 0,
background: "none",
marginBottom: "1rem", marginBottom: "1rem",
fontFamily: "var(--font-sans)",
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i
<path d="M19 12H5M12 5l-7 7 7 7" /> className="ti ti-arrow-left"
</svg> style={{ fontSize: 14 }}
aria-hidden="true"
/>
العودة إلى قائمة الرحلات العودة إلى قائمة الرحلات
</button> </Button>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}> <div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: "1rem",
}}
>
{/* Icon + title */} {/* Icon + title */}
<div style={{ display: "flex", alignItems: "center", gap: 16 }}> <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div <div
@@ -387,20 +418,56 @@ export default function TripDetailPage() {
justifyContent: "center", justifyContent: "center",
}} }}
> >
<span style={{ fontSize: 28, fontWeight: 700, color: "var(--color-brand-600)" }}> <span
style={{
fontSize: 28,
fontWeight: 700,
color: "var(--color-brand-600)",
}}
>
{trip.title.charAt(0)} {trip.title.charAt(0)}
</span> </span>
</div> </div>
<div> <div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> <p
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
margin: 0,
}}
>
ملف الرحلة ملف الرحلة
</p> </p>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}> <h1
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--color-text-primary)",
margin: "4px 0 0",
}}
>
{trip.title} {trip.title}
</h1> </h1>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6, flexWrap: "wrap" }}> <div
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}> style={{
display: "flex",
alignItems: "center",
gap: 10,
marginTop: 6,
flexWrap: "wrap",
}}
>
<span
style={{
fontSize: 12,
fontFamily: "var(--font-mono)",
color: "var(--color-text-muted)",
}}
>
#{trip.tripNumber} #{trip.tripNumber}
</span> </span>
{statusConfig && ( {statusConfig && (
@@ -418,7 +485,15 @@ export default function TripDetailPage() {
gap: 5, gap: 5,
}} }}
> >
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} /> <span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: statusConfig.dot,
flexShrink: 0,
}}
/>
{statusConfig.label} {statusConfig.label}
</span> </span>
)} )}
@@ -465,10 +540,11 @@ export default function TripDetailPage() {
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /> className="ti ti-edit"
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> style={{ fontSize: 14 }}
</svg> aria-hidden="true"
/>
تعديل الرحلة تعديل الرحلة
</button> </button>
</div> </div>
@@ -476,13 +552,35 @@ export default function TripDetailPage() {
</header> </header>
{/* ── Stats row ── */} {/* ── Stats row ── */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: "0.75rem" }}> <div
<StatCard label="المجمّع" value={trip.collectedCount} color="var(--color-brand-600)" /> style={{
<StatCard label="المُسلَّم" value={trip.deliveredCount} color="#16A34A" /> display: "grid",
<StatCard label="المُرتجع" value={trip.returnedCount} color="#D97706" /> gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
gap: "0.75rem",
}}
>
<StatCard
label="المجمّع"
value={trip.collectedCount}
color="var(--color-brand-600)"
/>
<StatCard
label="المُسلَّم"
value={trip.deliveredCount}
color="#16A34A"
/>
<StatCard
label="المُرتجع"
value={trip.returnedCount}
color="#D97706"
/>
<StatCard <StatCard
label="النقد المحصّل" label="النقد المحصّل"
value={trip.totalCashCollected != null ? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س` : "—"} value={
trip.totalCashCollected != null
? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س`
: "—"
}
color="#7C3AED" color="#7C3AED"
/> />
</div> </div>
@@ -497,45 +595,77 @@ export default function TripDetailPage() {
> >
{/* Trip Info */} {/* Trip Info */}
<SectionCard title="بيانات الرحلة"> <SectionCard title="بيانات الرحلة">
<DetailRow label="وقت البدء" value={fmtDateTime(trip.startTime)} /> <DetailRow label="وقت البدء" value={fmtDateTime(trip.startTime)} />
<DetailRow label="وقت الانتهاء" value={fmtDateTime(trip.endTime)} /> <DetailRow label="وقت الانتهاء" value={fmtDateTime(trip.endTime)} />
<DetailRow label="الفرع" value={trip.branch?.name ?? "—"} /> <DetailRow label="الفرع" value={trip.branch?.name ?? "—"} />
{trip.notes && <DetailRow label="ملاحظات" value={trip.notes} />} {trip.notes && <DetailRow label="ملاحظات" value={trip.notes} />}
{trip.endReason && <DetailRow label="سبب الإنهاء" value={trip.endReason} warn />} {trip.endReason && (
<DetailRow label="سبب الإنهاء" value={trip.endReason} warn />
)}
</SectionCard> </SectionCard>
{/* Driver */} {/* Driver */}
{trip.driver && ( {trip.driver && (
<SectionCard title="بيانات السائق"> <SectionCard title="بيانات السائق">
<DetailRow label="الاسم" value={trip.driver.name} /> <DetailRow label="الاسم" value={trip.driver.name} />
<DetailRow label="الجوال" value={trip.driver.phone} mono /> <DetailRow label="الجوال" value={trip.driver.phone} mono />
<DetailRow label="اسم المستخدم" value={trip.driver.userName ?? "—"} mono /> <DetailRow
<DetailRow label="البريد" value={trip.driver.email ?? "—"} /> label="اسم المستخدم"
<DetailRow label="الجنسية" value={trip.driver.nationality ?? "—"} /> value={trip.driver.userName ?? "—"}
<DetailRow label="رخصة القيادة" value={trip.driver.licenseNumber ?? "—"} mono /> mono
<DetailRow label="بطاقة السائق" value={trip.driver.driverCardNumber ?? "—"} mono /> />
<DetailRow label="رقم GOSI" value={trip.driver.gosiNumber ?? "—"} mono /> <DetailRow label="البريد" value={trip.driver.email ?? "—"} />
<DetailRow
label="الجنسية"
value={trip.driver.nationality ?? "—"}
/>
<DetailRow
label="رخصة القيادة"
value={trip.driver.licenseNumber ?? "—"}
mono
/>
<DetailRow
label="بطاقة السائق"
value={trip.driver.driverCardNumber ?? "—"}
mono
/>
<DetailRow
label="رقم GOSI"
value={trip.driver.gosiNumber ?? "—"}
mono
/>
</SectionCard> </SectionCard>
)} )}
{/* Car */} {/* Car */}
{trip.car && ( {trip.car && (
<SectionCard title="بيانات السيارة"> <SectionCard title="بيانات السيارة">
<DetailRow label="الشركة" value={trip.car.manufacturer} /> <DetailRow label="الشركة" value={trip.car.manufacturer} />
<DetailRow label="الموديل" value={trip.car.model} /> <DetailRow label="الموديل" value={trip.car.model} />
<DetailRow label="السنة" value={trip.car.year != null ? String(trip.car.year) : "—"} /> <DetailRow
<DetailRow label="اللون" value={trip.car.color ?? "—"} /> label="السنة"
<DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono /> value={trip.car.year != null ? String(trip.car.year) : "—"}
<DetailRow label="حروف اللوحة" value={trip.car.plateLetters ?? "—"} mono /> />
<DetailRow label="رقم التسجيل" value={trip.car.registrationNumber ?? "—"} mono /> <DetailRow label="اللون" value={trip.car.color ?? "—"} />
<DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono />
<DetailRow
label="حروف اللوحة"
value={trip.car.plateLetters ?? "—"}
mono
/>
<DetailRow
label="رقم التسجيل"
value={trip.car.registrationNumber ?? "—"}
mono
/>
</SectionCard> </SectionCard>
)} )}
{/* System Info */} {/* System Info */}
<SectionCard title="معلومات النظام"> <SectionCard title="معلومات النظام">
<DetailRow label="رقم الرحلة" value={trip.tripNumber} mono /> <DetailRow label="رقم الرحلة" value={trip.tripNumber} mono />
<DetailRow label="تاريخ الإنشاء" value={fmtDate(trip.createdAt)} /> <DetailRow label="تاريخ الإنشاء" value={fmtDate(trip.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(trip.updatedAt)} /> <DetailRow label="آخر تحديث" value={fmtDate(trip.updatedAt)} />
</SectionCard> </SectionCard>
</div> </div>
@@ -551,12 +681,14 @@ export default function TripDetailPage() {
> >
<TripReportPanel tripId={trip.id} /> <TripReportPanel tripId={trip.id} />
</div> </div>
</section> </section>
{/* ── Edit modal ── */} {/* ── Edit modal ── */}
{editOpen && ( {editOpen && (
<TripFormModal <TripFormModal
// CHANGE: added key — protects against stale form state if `trip`
// is refetched with a new object identity (e.g. after loadTrip()).
key={trip.id}
editTrip={trip} editTrip={trip}
onClose={() => setEditOpen(false)} onClose={() => setEditOpen(false)}
onSubmit={handleEditSubmit} onSubmit={handleEditSubmit}
@@ -574,4 +706,4 @@ export default function TripDetailPage() {
/> />
</> </>
); );
} }

View File

@@ -1,16 +1,7 @@
"use client"; "use client";
import { ArchivedTripDetailModal } from "@/src/Components/Trip/archive/ArchivedtripDetailModal"; import { ArchivedTripDetailModal } from "@/src/Components/Trip/archive/ArchivedtripDetailModal";
// app/dashboard/trips/archived/[tripId]/page.tsx
// CHANGE: rebuilt as a thin route wrapper around the shared
// ArchivedTripDetailModal instead of its own independent fetch/loading/error/
// render logic. This route now shows the exact same full detail view as the
// in-app archive browser (driver + car + counts + cash + notes + endReason,
// not just the handful of fields this page used to render on its own), and
// stays in sync automatically with any future changes to that component.
// `onClose` navigates back to the trips list (via router.push) instead of
// closing an in-place modal, since this route has no page behind it to
// reveal.
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";

View File

@@ -15,8 +15,17 @@ import { useRouter } from "next/navigation";
import { useTrips } from "@/src/hooks/useTrip"; import { useTrips } from "@/src/hooks/useTrip";
import { TripFormModal, TripTable } from "@/src/Components/Trip"; import { TripFormModal, TripTable } from "@/src/Components/Trip";
import { ArchivedTripModal } from "@/src/Components/Trip/archive/ArchivedTripModal"; import { ArchivedTripModal } from "@/src/Components/Trip/archive/ArchivedTripModal";
import { Alert, ArchiveButton, ConfirmDialog, Toast } from "@/src/Components/UI"; import {
import type { Trip, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip"; Alert,
ArchiveButton,
ConfirmDialog,
Toast,
} from "@/src/Components/UI";
import type {
Trip,
CreateTripPayload,
UpdateTripPayload,
} from "@/src/types/trip";
import Header from "@/src/Components/UI/Header"; import Header from "@/src/Components/UI/Header";
// ── Page ────────────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────────────
@@ -147,6 +156,9 @@ export default function TripsPage() {
{/* ── Form modal ── */} {/* ── Form modal ── */}
{showForm && ( {showForm && (
<TripFormModal <TripFormModal
// CHANGE: added key — ensures defaultValues reset correctly when
// switching between "add new trip" and editing different trips.
key={editTrip === null ? "new" : editTrip.id}
editTrip={editTrip} editTrip={editTrip}
onClose={() => setShowForm(false)} onClose={() => setShowForm(false)}
onSubmit={async (payload, isNew) => { onSubmit={async (payload, isNew) => {
@@ -170,7 +182,9 @@ export default function TripsPage() {
{/* ── Archive browser modal — now the split ArchivedTripModal directly, {/* ── Archive browser modal — now the split ArchivedTripModal directly,
no local wrapper needed since it already ships its own search bar, no local wrapper needed since it already ships its own search bar,
table, and detail-view wiring. ── */} table, and detail-view wiring. ── */}
{archiveOpen && <ArchivedTripModal onClose={() => setArchiveOpen(false)} />} {archiveOpen && (
<ArchivedTripModal onClose={() => setArchiveOpen(false)} />
)}
{/* ── Floating archive button — same component Driver page uses ── */} {/* ── Floating archive button — same component Driver page uses ── */}
<ArchiveButton onClick={() => setArchiveOpen(true)} label="الأرشيف" /> <ArchiveButton onClick={() => setArchiveOpen(true)} label="الأرشيف" />
@@ -178,4 +192,4 @@ export default function TripsPage() {
<Toast notification={notification} onDismiss={dismissNotification} /> <Toast notification={notification} onDismiss={dismissNotification} />
</div> </div>
); );
} }

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

@@ -39,10 +39,7 @@ function BranchIcon() {
flexShrink: 0, flexShrink: 0,
boxShadow: "0 4px 12px rgba(37,99,235,.3)", boxShadow: "0 4px 12px rgba(37,99,235,.3)",
}}> }}>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#FFF" strokeWidth="2"> <i className="ti ti-building" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
<path d="M9 9v.01M9 12v.01M9 15v.01M9 18v.01" />
</svg>
</div> </div>
); );
} }

View File

@@ -1,54 +1,144 @@
"use client"; "use client";
import { useForm } from "react-hook-form"; import { useEffect, useMemo, useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, Input, Modal } from "../UI"; import { Alert, Button, Input, Modal, Select } from "../UI";
import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator"; import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator";
import {
getRegions,
getCitiesByRegion,
getDistrictsByCity,
resolveLocation,
findLocationByNames,
} from "@/src/lib/locationHierarchy";
import type { Branch, BranchFormData } from "@/src/types/branch"; import type { Branch, BranchFormData } from "@/src/types/branch";
const FORM_ID = "branch-form"; const FORM_ID = "branch-form";
// Step 1: Extend the plain form-values shape with the three hierarchy ids.
// BranchFormData itself stays untouched (see src/types/branch.ts) — city/
// state/district there are still plain strings, since that's the API
// contract. This local type is what react-hook-form actually manages.
interface BranchFormValues extends BranchFormData {
regionId: string;
cityId: string;
districtId: string;
}
// ── Props ───────────────────────────────────────────────────────────────────── // ── Props ─────────────────────────────────────────────────────────────────────
interface BranchFormModalProps { interface BranchFormModalProps {
editBranch: Branch | null; editBranch: Branch | null;
onClose: () => void; onClose: () => void;
onSubmit: (data: BranchFormData, isNew: boolean) => Promise<boolean>; onSubmit: (data: BranchFormData, isNew: boolean) => Promise<boolean>;
} }
// ── main component ──────────────────────────────────────────────────────────── // ── main component ────────────────────────────────────────────────────────────
export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) { export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) {
const isNew = editBranch === null; const isNew = editBranch === null;
// Step 2: On edit, try to resolve the branch's stored city/state/district
// NAMES back into ids against the current dataset. If nothing matches
// (the value is a legacy/unknown location no longer in the hierarchy),
// resolved stays null and the form falls back to empty selects — the
// person must actively re-pick a valid Region -> City -> District chain
// before they can save, rather than silently keeping a stale value.
const resolvedEdit = useMemo(
() => (editBranch ? findLocationByNames(editBranch.state, editBranch.city, editBranch.district) : null),
[editBranch],
);
const isLegacyLocation = !isNew && !resolvedEdit && !!(editBranch?.city || editBranch?.district);
const { const {
register, register,
handleSubmit, handleSubmit,
setError, setError,
setValue,
watch,
control,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<BranchFormData>({ } = useForm<BranchFormValues>({
// Cast the schema itself and pin the generic explicitly on yupResolver — resolver: yupResolver<BranchFormValues>((isNew ? createBranchSchema : updateBranchSchema) as any),
// create/update schemas differ structurally in which fields are required
// (e.g. city/street are optional on update), so yup can't unify them
// into the single flat BranchFormData shape on its own.
resolver: yupResolver<BranchFormData>((isNew ? createBranchSchema : updateBranchSchema) as any),
defaultValues: { defaultValues: {
name: editBranch?.name ?? "", name: editBranch?.name ?? "",
email: editBranch?.email ?? "", email: editBranch?.email ?? "",
phone: editBranch?.phone ?? "", phone: editBranch?.phone ?? "",
country: editBranch?.country ?? "SA", country: editBranch?.country ?? "SA",
city: editBranch?.city ?? "", regionId: resolvedEdit?.regionId ?? "",
state: editBranch?.state ?? "", cityId: resolvedEdit?.cityId ?? "",
district: editBranch?.district ?? "", districtId: resolvedEdit?.districtId ?? "",
street: editBranch?.street ?? "", city: editBranch?.city ?? "",
state: editBranch?.state ?? "",
district: editBranch?.district ?? "",
street: editBranch?.street ?? "",
buildingNo: editBranch?.buildingNo ?? "", buildingNo: editBranch?.buildingNo ?? "",
unitNo: editBranch?.unitNo ?? "", unitNo: editBranch?.unitNo ?? "",
zipCode: editBranch?.zipCode ?? "", zipCode: editBranch?.zipCode ?? "",
latitude: editBranch?.latitude != null ? String(editBranch.latitude) : "", latitude: resolvedEdit?.latitude != null ? String(resolvedEdit.latitude) : editBranch?.latitude != null ? String(editBranch.latitude) : "",
longitude: editBranch?.longitude != null ? String(editBranch.longitude) : "", longitude: resolvedEdit?.longitude != null ? String(resolvedEdit.longitude) : editBranch?.longitude != null ? String(editBranch.longitude) : "",
}, },
}); });
const submitHandler = async (data: BranchFormData) => { // Step 3: Watch the two parent selects so the child dropdowns can filter
const ok = await onSubmit(data, isNew); // themselves reactively (cascading Region -> City -> District).
const watchedRegionId = watch("regionId");
const watchedCityId = watch("cityId");
const watchedDistrictId = watch("districtId");
const regions = useMemo(() => getRegions(), []);
const cities = useMemo(() => getCitiesByRegion(watchedRegionId), [watchedRegionId]);
const districts = useMemo(
() => getDistrictsByCity(watchedRegionId, watchedCityId),
[watchedRegionId, watchedCityId],
);
// Step 4: Whenever the region changes, clear any city/district selection
// that no longer belongs to it — prevents the exact "Mohandessin under
// Cairo" class of bug from ever reaching submit.
useEffect(() => {
if (!watchedCityId) return;
const stillValid = cities.some((c) => c.id === watchedCityId);
if (!stillValid) {
setValue("cityId", "");
setValue("districtId", "");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchedRegionId]);
useEffect(() => {
if (!watchedDistrictId) return;
const stillValid = districts.some((d) => d.id === watchedDistrictId);
if (!stillValid) setValue("districtId", "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchedCityId]);
// Step 5: Whenever the fully-resolved district changes, auto-populate
// latitude/longitude from the dataset — no manual coordinate entry.
useEffect(() => {
const resolved = resolveLocation(watchedRegionId, watchedCityId, watchedDistrictId);
if (resolved) {
setValue("latitude", String(resolved.latitude));
setValue("longitude", String(resolved.longitude));
}
}, [watchedRegionId, watchedCityId, watchedDistrictId, setValue]);
const submitHandler = async (data: BranchFormValues) => {
// Step 6: Resolve the chosen ids into display names right before
// building the payload, so the API keeps receiving plain strings for
// city/state/district exactly as it does today — this UI change is
// fully transparent to the backend contract.
const resolved = resolveLocation(data.regionId, data.cityId, data.districtId);
const payload: BranchFormData = {
...data,
state: resolved?.regionName ?? data.state,
city: resolved?.cityName ?? data.city,
district: resolved?.districtName ?? data.district,
latitude: resolved ? String(resolved.latitude) : data.latitude,
longitude: resolved ? String(resolved.longitude) : data.longitude,
};
const ok = await onSubmit(payload, isNew);
if (ok) { if (ok) {
onClose(); onClose();
} else { } else {
@@ -84,6 +174,17 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} /> <Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
)} )}
{/* Step 7: Warn once if the stored location no longer matches any
valid Region -> City -> District chain, and require a fresh
selection instead of silently keeping the legacy value. */}
{isLegacyLocation && (
<Alert
type="warning"
message="الموقع المحفوظ لهذا الفرع لم يعد مطابقًا لأي منطقة/مدينة/حي معروف. الرجاء اختيار الموقع الصحيح من القوائم أدناه."
onClose={() => {}}
/>
)}
{/* name */} {/* name */}
<Input <Input
label="اسم الفرع *" label="اسم الفرع *"
@@ -117,15 +218,73 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
/> />
</div> </div>
{/* city + street */} {/* Step 8: Cascading Region -> City -> District selects. Each
child <Select> is disabled until its parent has a value, and
its options come straight from the resolved parent id — a
district literally cannot be picked unless it belongs to the
currently-selected city. */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input <Controller
label="المدينة *" name="regionId"
{...register("city")} control={control}
error={errors.city?.message} render={({ field }) => (
placeholder="الرياض" <Select
dir="rtl" label="المنطقة *"
value={field.value}
onChange={field.onChange}
error={(errors as any).regionId?.message}
dir="rtl"
>
<option value="">اختر المنطقة</option>
{regions.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</Select>
)}
/> />
<Controller
name="cityId"
control={control}
render={({ field }) => (
<Select
label="المدينة *"
value={field.value}
onChange={field.onChange}
error={(errors as any).cityId?.message}
disabled={!watchedRegionId}
dir="rtl"
>
<option value="">{watchedRegionId ? "اختر المدينة" : "اختر المنطقة أولاً"}</option>
{cities.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</Select>
)}
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Controller
name="districtId"
control={control}
render={({ field }) => (
<Select
label="الحي *"
value={field.value}
onChange={field.onChange}
error={(errors as any).districtId?.message}
disabled={!watchedCityId}
dir="rtl"
>
<option value="">{watchedCityId ? "اختر الحي" : "اختر المدينة أولاً"}</option>
{districts.map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
)}
/>
<Input <Input
label="الشارع *" label="الشارع *"
{...register("street")} {...register("street")}
@@ -135,24 +294,6 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
/> />
</div> </div>
{/* state + district */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input
label="المنطقة"
{...register("state")}
error={errors.state?.message}
placeholder="منطقة الرياض"
dir="rtl"
/>
<Input
label="الحي"
{...register("district")}
error={errors.district?.message}
placeholder="حي العليا"
dir="rtl"
/>
</div>
{/* buildingNo + unitNo + zipCode */} {/* buildingNo + unitNo + zipCode */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<Input <Input
@@ -185,25 +326,33 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
error={errors.country?.message} error={errors.country?.message}
placeholder="SA" placeholder="SA"
dir="ltr" dir="ltr"
disabled
/> />
{/* latitude + longitude */} {/* Step 9: latitude/longitude are now READ-ONLY — they are derived
automatically from the selected district (Step 5 above), so
there is no manual entry and therefore no coordinate-vs-district
mismatch possible from this form. */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input <Input
label="خط العرض (اختياري)" label="خط العرض (تلقائي)"
{...register("latitude")} {...register("latitude")}
error={errors.latitude?.message} error={errors.latitude?.message}
placeholder="24.7136" placeholder=""
dir="ltr" dir="ltr"
inputMode="decimal" inputMode="decimal"
readOnly
disabled
/> />
<Input <Input
label="خط الطول (اختياري)" label="خط الطول (تلقائي)"
{...register("longitude")} {...register("longitude")}
error={errors.longitude?.message} error={errors.longitude?.message}
placeholder="46.6753" placeholder=""
dir="ltr" dir="ltr"
inputMode="decimal" inputMode="decimal"
readOnly
disabled
/> />
</div> </div>
</form> </form>

View File

@@ -1,134 +1,96 @@
"use client"; "use client";
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../UI"; // src/Components/Branch/BranchTable.tsx
// MIGRATED to ReusableTable + ActionButtons — same props, same visual
// result as the original hand-rolled version. No changes required in
// app/dashboard/branches/page.tsx.
import { Badge, Button, ReusableTable, ActionButtons } from "../UI";
import type { TableColumn } from "@/src/types/models";
import type { Branch } from "@/src/types/branch"; import type { Branch } from "@/src/types/branch";
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Props ────────────────────────────────────────────────────────────────────
interface BranchTableProps { interface BranchTableProps {
branches: Branch[]; branches: Branch[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onEdit: (branch: Branch) => void; onEdit: (branch: Branch) => void;
onDelete: (branch: Branch) => void; onDelete: (branch: Branch) => void;
onView: (branch: Branch) => void; onView: (branch: Branch) => void;
onAddFirst: () => void; onAddFirst: () => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ─────────────────────────────────────────────────────────────── export function BranchTable({
export function BranchTable({ branches, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: BranchTableProps) { branches, loading, search, page, pages,
onEdit, onDelete, onView, onAddFirst, onPageChange,
}: BranchTableProps) {
const columns: TableColumn<Branch>[] = [
{
key: "name",
header: "اسم الفرع",
width: "2fr",
render: (b) => (
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
</div>
),
},
{ key: "city", header: "المدينة", width: "1.5fr", render: (b) => b.city || "—" },
{
key: "phone",
header: "رقم الجوال",
width: "1.5fr",
render: (b) => (
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-secondary)" }}>
{b.phone ?? "—"}
</span>
),
},
{
key: "status",
header: "الحالة",
width: "1fr",
render: (b) => <Badge label={b.isActive ? "Active" : "Disabled"} color={b.isActive ? "green" : "red"} />,
},
{
key: "createdAt",
header: "تاريخ الانشاء",
width: "1fr",
align: "center",
render: (b) =>
new Date(b.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
},
];
return ( return (
<div style={cardStyle}> <ReusableTable
{/* column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 100px", ...thStyle }}> data={branches}
<span>الفرع</span> loading={loading}
<span>المدينة</span> search={search}
<span>الهاتف</span> page={page}
<span>الحالة</span> pages={pages}
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> onPageChange={onPageChange}
<span style={{ textAlign: "center" }}>إجراءات</span> emptyIcon="🏢"
</div> emptyDescription={!search ? "No branches to display." : undefined}
emptyAction={
{/* loading state */} !search && (
{loading ? ( <Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> Add first branch
<Spinner size="sm" className="text-blue-600" /> </Button>
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> )
</div> }
) : branches.length === 0 ? ( renderActions={(b) => (
/* empty state */ <ActionButtons
<EmptyState itemLabel={b.name}
icon="🏢" onView={() => onView(b)}
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."} onEdit={() => onEdit(b)}
action={ onDelete={() => onDelete(b)}
!search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
أضف أول فرع
</Button>
)
}
/> />
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{branches.map((b, i) => (
<li key={b.id} style={{
display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
</div>
<span style={{ color: "var(--color-text-secondary)" }}>{b.city || "—"}</span>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-secondary)" }}>{b.phone ?? "—"}</span>
<Badge label={b.isActive ? "نشط" : "معطل"} color={b.isActive ? "green" : "red"} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view */}
<IconBtn title={`عرض ${b.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(b)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
{/* edit */}
<IconBtn title={`تعديل ${b.name}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(b)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</IconBtn>
{/* delete */}
<IconBtn title={`حذف ${b.name}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(b)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)} )}
/>
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}>
{btn.label}
</Button>
))}
</div>
</div>
)}
</div>
); );
} }

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

@@ -35,10 +35,7 @@ function BranchIcon() {
flexShrink: 0, flexShrink: 0,
boxShadow: "0 4px 12px rgba(234,88,12,.3)", boxShadow: "0 4px 12px rgba(234,88,12,.3)",
}}> }}>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#FFF" strokeWidth="2"> <i className="ti ti-building" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
<path d="M9 9v.01M9 12v.01M9 15v.01M9 18v.01" />
</svg>
</div> </div>
); );
} }

View File

@@ -1,121 +1,70 @@
"use client"; "use client";
import { Badge, Button, IconBtn, Spinner } from "../../UI"; // src/Components/Branch/archive/ArchivedBranchTable.tsx
// MIGRATED to ReusableTable + ActionButtons. View-only, no row click in
// the original.
import { Badge, ReusableTable, ActionButtons } from "../../UI";
import type { TableColumn } from "@/src/types/models";
import type { ArchivedBranch } from "@/src/types/branch"; import type { ArchivedBranch } from "@/src/types/branch";
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Props ────────────────────────────────────────────────────────────────────
interface ArchivedBranchTableProps { interface ArchivedBranchTableProps {
branches: ArchivedBranch[]; branches: ArchivedBranch[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onView: (branch: ArchivedBranch) => void; onView: (branch: ArchivedBranch) => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ───────────────────────────────────────────────────────────────
export function ArchivedBranchTable({ branches, loading, search, page, pages, onView, onPageChange }: ArchivedBranchTableProps) { export function ArchivedBranchTable({ branches, loading, search, page, pages, onView, onPageChange }: ArchivedBranchTableProps) {
const columns: TableColumn<ArchivedBranch>[] = [
{
key: "name",
header: "اسم الفرع",
width: "2fr",
render: (b) => (
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
</div>
),
},
{ key: "city", header: "المدينة", width: "1.5fr", render: (b) => b.city || "—" },
{
key: "status",
header: "الحالة",
width: "1fr",
render: (b) => <Badge label={b.isActive ? "Active" : "Disabled"} color={b.isActive ? "green" : "red"} />,
},
{
key: "createdAt",
header: "تاريخ الانشاء",
width: "1fr",
align: "center",
render: (b) => new Date(b.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
},
{
key: "updatedAt",
header: "تاريخ التعديل",
width: "1fr",
align: "center",
render: (b) => new Date(b.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
},
];
return ( return (
<div style={cardStyle}> <ReusableTable
{/* column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> data={branches}
<span>الفرع</span> loading={loading}
<span>المدينة</span> search={search}
<span>الحالة</span> page={page}
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> pages={pages}
<span style={{ textAlign: "center" }}>آخر تحديث</span> onPageChange={onPageChange}
<span style={{ textAlign: "center" }}>إجراءات</span> emptyDescription={search ? `No results for "${search}"` : "No archived branches."}
</div> renderActions={(b) => <ActionButtons itemLabel={b.name} onView={() => onView(b)} />}
/>
{/* loading state */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : branches.length === 0 ? (
/* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع في الأرشيف."}
</p>
</div>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{branches.map((b, i) => (
<li key={b.id} style={{
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
</div>
<span style={{ color: "var(--color-text-secondary)" }}>{b.city || "—"}</span>
<Badge label={b.isActive ? "نشط" : "معطل"} color={b.isActive ? "green" : "red"} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(b.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view only — delete/restore not implemented yet */}
<IconBtn title={`عرض ${b.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(b)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)}
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
<Button
variant="secondary"
size="sm"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page === 1}
>
السابق
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onPageChange(Math.min(pages, page + 1))}
disabled={page === pages}
>
التالي
</Button>
</div>
</div>
)}
</div>
); );
} }

View File

@@ -12,9 +12,7 @@ interface ArchivedBranchesModalProps {
} }
const SearchIcon = ( const SearchIcon = (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
); );
export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) { export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {

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,19 +1,11 @@
"use client"; "use client";
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
import { fmtDateShort, fmtCost } from "@/src/types/carMaintanance";
import type { CarMaintenance } from "@/src/types/carMaintanance";
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", import { IconBtn, ReusableTable } from "../../UI";
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)", import { fmtDateShort, fmtCost } from "@/src/types/carMaintanance";
}; import type { TableColumn } from "@/src/types/models";
const thStyle: React.CSSProperties = { import type { CarMaintenance } from "@/src/types/carMaintanance";
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
interface ArchivedCarMaintenanceTableProps { interface ArchivedCarMaintenanceTableProps {
records: CarMaintenance[]; records: CarMaintenance[];
@@ -30,85 +22,78 @@ interface ArchivedCarMaintenanceTableProps {
export function ArchivedCarMaintenanceTable({ export function ArchivedCarMaintenanceTable({
records, loading, search, page, pages, showCar = false, onView, onPageChange, records, loading, search, page, pages, showCar = false, onView, onPageChange,
}: ArchivedCarMaintenanceTableProps) { }: ArchivedCarMaintenanceTableProps) {
const columns = showCar ? "2fr 1fr 1fr 1fr 1fr 100px" : "2fr 1fr 1fr 1fr 100px"; const columns: TableColumn<CarMaintenance>[] = [
{
key: "reason",
header: "سبب الصيانة",
width: "2fr",
render: (r) => (
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{r.reason}</p>
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>أضيف {fmtDateShort(r.createdAt)}</p>
</div>
),
},
// Only shown in the global (cross-car) archive view.
...(showCar
? [{
key: "car",
header: "المركبة",
width: "1fr",
render: (r: CarMaintenance) => (
<span style={{ color: "var(--color-text-secondary)" }}>
{r.car ? `${r.car.manufacturer} ${r.car.model}` : "—"}
</span>
),
} as TableColumn<CarMaintenance>]
: []),
{
key: "cost",
header: "التكلفة",
width: "1fr",
render: (r) => (
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
{fmtCost(r.cost)}
</span>
),
},
{ key: "startAt", header: "تاريخ البدء", width: "1fr", render: (r) => (
<span style={{ color: "var(--color-text-secondary)" }}>{fmtDateShort(r.startAt)}</span>
),
},
{
key: "endAt",
header: "تاريخ الانتهاء",
width: "1fr",
render: (r) => (
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
{fmtDateShort(r.endAt)}
</span>
),
},
];
return ( return (
<div style={cardStyle}> <ReusableTable
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: columns, ...thStyle }}> columns={columns}
<span>سبب الصيانة</span> data={records}
{showCar && <span>المركبة</span>} loading={loading}
<span>التكلفة</span> search={search}
<span>تاريخ البدء</span> page={page}
<span style={{ textAlign: "center" }}>تاريخ الانتهاء</span> pages={pages}
<span style={{ textAlign: "center" }}>إجراءات</span> onPageChange={onPageChange}
</div> actionsWidth="100px"
onRowClick={onView}
{loading ? ( emptyDescription={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."}
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> renderActions={(r) => (
<Spinner size="sm" className="text-blue-600" /> <IconBtn
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> title={`عرض ${r.reason}`}
</div> color="#059669" bg="#ECFDF5" borderColor="#A7F3D0"
) : records.length === 0 ? ( onClick={() => onView(r)}
<EmptyState >
icon="🔧" <i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."} </IconBtn>
/>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{records.map((r, i) => (
<li key={r.id} style={{
display: "grid", gridTemplateColumns: columns,
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{r.reason}</p>
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>أضيف {fmtDateShort(r.createdAt)}</p>
</div>
{showCar && (
<span style={{ color: "var(--color-text-secondary)" }}>
{r.car ? `${r.car.manufacturer} ${r.car.model}` : "—"}
</span>
)}
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
{fmtCost(r.cost)}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>{fmtDateShort(r.startAt)}</span>
<span style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-muted)" }}>
{fmtDateShort(r.endAt)}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
<IconBtn title={`عرض ${r.reason}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(r)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)} )}
/>
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}>
{btn.label}
</Button>
))}
</div>
</div>
)}
</div>
); );
} }

View File

@@ -51,9 +51,7 @@ export function ArchivedCarsMaintenanceModal({ carId, carLabel, onClose }: Archi
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
dir="rtl" dir="rtl"
icon={ icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
} }
/> />
</div> </div>

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,18 +1,35 @@
"use client"; "use client";
// src/Components/Client/Clienttable.tsx
// MIGRATED to ReusableTable — this is the one table in the project with
// extra behavior (a click-to-expand detail panel below the row), so it
// relies on ReusableTable's `renderExpanded` prop (added specifically to
// cover this case) instead of the plain `renderActions`/`onRowClick` pair
// every other table uses. AddressBadge and ClientDetailPanel are kept
// exactly as in the original — purpose-built layouts with no shared-UI
// equivalent, same rationale as the original file's comments.
import { useState } from "react"; import { IconBtn, ReusableTable, Button } from "../UI";
import { Button, EmptyState, IconBtn, Spinner } from "../UI"; import type { TableColumn } from "@/src/types/models";
import type { Client } from "@/src/types/client"; import type { Client } from "@/src/types/client";
// ── Address count badge ──────────────────────────────────────────────────── // ── Address count badge ────────────────────────────────────────────────────
function AddressBadge({ count, onClick }: { count: number; onClick: () => void }) { function AddressBadge({
count,
onClick,
}: {
count: number;
onClick: () => void;
}) {
const hasAddr = count > 0; const hasAddr = count > 0;
return ( return (
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); onClick(); }} onClick={(e) => {
title="إدارة العناوين" e.stopPropagation();
onClick();
}}
title="ادارة العناوين"
style={{ style={{
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
@@ -29,25 +46,13 @@ function AddressBadge({ count, onClick }: { count: number; onClick: () => void }
transition: "opacity 150ms", transition: "opacity 150ms",
}} }}
> >
{count} {count === 1 ? "عنوان" : "عناوين"} {count} {count === 1 ? "address" : "العناوين"}
{/* Small arrow icon to hint navigation */} <i className="ti ti-chevron-right" style={{ fontSize: 10 }} aria-hidden="true" />
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M9 18l6-6-6-6" />
</svg>
</button> </button>
); );
} }
// ── Client detail panel (inline expandable) ──────────────────────────────── // ── Client detail panel (the expandable content) ───────────────────────────
/**
* ClientDetailPanel — shown below the clicked row.
* Displays all client fields (name, email, phone, taxId, notes, createdAt)
* and provides a direct link to the addresses sub-page.
*
* @param client The client whose details are rendered.
* @param onGoAddresses Callback that navigates to /dashboard/clients/[id]/addresses.
* @param onEdit Opens the edit modal for this client.
*/
function ClientDetailPanel({ function ClientDetailPanel({
client, client,
onGoAddresses, onGoAddresses,
@@ -85,10 +90,8 @@ function ClientDetailPanel({
return ( return (
<div <div
dir="rtl"
style={{ style={{
/* Slide-down visual treatment: left accent border in brand blue */ borderInlineStart: "3px solid var(--color-brand-600)",
borderRight: "3px solid var(--color-brand-600)",
background: "linear-gradient(135deg, #EFF6FF 0%, #F0F9FF 100%)", background: "linear-gradient(135deg, #EFF6FF 0%, #F0F9FF 100%)",
borderBottom: "1px solid #BFDBFE", borderBottom: "1px solid #BFDBFE",
padding: "1.25rem 1.5rem", padding: "1.25rem 1.5rem",
@@ -98,7 +101,6 @@ function ClientDetailPanel({
animation: "detailFadeIn 180ms ease", animation: "detailFadeIn 180ms ease",
}} }}
> >
{/* ── Top row: name + quick actions ── */}
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -138,36 +140,29 @@ function ClientDetailPanel({
)} )}
</div> </div>
{/* Action buttons */}
<div style={{ display: "flex", gap: "0.5rem", flexShrink: 0 }}> <div style={{ display: "flex", gap: "0.5rem", flexShrink: 0 }}>
{/* Edit client */}
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={(e) => { e.stopPropagation(); onEdit(); }} onClick={(e) => {
e.stopPropagation();
onEdit();
}}
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-edit" style={{ fontSize: 12 }} aria-hidden="true" />
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /> تعديل التفاصيل
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
تعديل البيانات
</Button> </Button>
{/*
* "Addresses" button — KEY FEATURE:
* Navigates to /dashboard/clients/[clientId]/addresses
*/}
<Button <Button
type="button" type="button"
variant="primary" variant="primary"
size="sm" size="sm"
onClick={(e) => { e.stopPropagation(); onGoAddresses(); }} onClick={(e) => {
e.stopPropagation();
onGoAddresses();
}}
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-map-pin" style={{ fontSize: 12 }} aria-hidden="true" />
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
العناوين العناوين
{client.addresses?.length !== undefined && ( {client.addresses?.length !== undefined && (
<span <span
@@ -187,7 +182,6 @@ function ClientDetailPanel({
</div> </div>
</div> </div>
{/* ── Info grid ── */}
<div <div
style={{ style={{
display: "grid", display: "grid",
@@ -198,7 +192,7 @@ function ClientDetailPanel({
}} }}
> >
{infoItem( {infoItem(
"البريد الإلكتروني", "البريد الالكتروني",
<a <a
href={`mailto:${client.email}`} href={`mailto:${client.email}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -207,27 +201,26 @@ function ClientDetailPanel({
{client.email} {client.email}
</a>, </a>,
)} )}
{infoItem("الهاتف", client.phone)} {infoItem("رقم الجوال", client.phone)}
{infoItem( {infoItem(
"تاريخ الإضافة", "تاريخ الانشاء",
new Date(client.createdAt).toLocaleDateString("ar-SA", { new Date(client.createdAt).toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}), }),
)} )}
{infoItem( {infoItem(
"آخر تحديث", "اخر تحديث",
new Date(client.updatedAt).toLocaleDateString("ar-SA", { new Date(client.updatedAt).toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}), }),
)} )}
{client.notes && infoItem("ملاحظات", client.notes)} {client.notes && infoItem("Notes", client.notes)}
</div> </div>
{/* Keyframe animation injected via a style tag (scoped) */}
<style>{` <style>{`
@keyframes detailFadeIn { @keyframes detailFadeIn {
from { opacity: 0; transform: translateY(-6px); } from { opacity: 0; transform: translateY(-6px); }
@@ -238,26 +231,6 @@ function ClientDetailPanel({
); );
} }
// ── Shared card & header styles ────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem",
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.2em",
color: "var(--color-text-muted)",
background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Props ────────────────────────────────────────────────────────────────── // ── Props ──────────────────────────────────────────────────────────────────
interface ClientTableProps { interface ClientTableProps {
clients: Client[]; clients: Client[];
@@ -269,7 +242,6 @@ interface ClientTableProps {
onDelete: (client: Client) => void; onDelete: (client: Client) => void;
onAddFirst: () => void; onAddFirst: () => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
/** Navigate to address management for a client */
onManageAddresses: (client: Client) => void; onManageAddresses: (client: Client) => void;
} }
@@ -286,311 +258,155 @@ export function ClientTable({
onPageChange, onPageChange,
onManageAddresses, onManageAddresses,
}: ClientTableProps) { }: ClientTableProps) {
/** const columns: TableColumn<Client>[] = [
* expandedId — tracks which client row is currently expanded. {
* Clicking the same row again collapses it (toggle behaviour). key: "name",
*/ header: "اسم العميل",
const [expandedId, setExpandedId] = useState<string | null>(null); width: "2fr",
render: (c, _i, isExpanded) => (
const handleRowClick = (client: Client) => { <div>
// Toggle: collapse if already open, expand otherwise <p
setExpandedId((prev) => (prev === client.id ? null : client.id)); style={{
}; fontWeight: 600,
color: "var(--color-text-primary)",
margin: 0,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
{c.name}
<i
className="ti ti-chevron-right"
aria-hidden="true"
style={{
fontSize: 12,
flexShrink: 0,
color: "var(--color-text-muted)",
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
transition: "transform 200ms",
display: "inline-block",
}}
/>
</p>
{c.taxId && (
<p
style={{
marginTop: 2,
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--color-text-muted)",
}}
>
{c.taxId}
</p>
)}
</div>
),
},
{
key: "email",
header: "البريد الالكتروني",
width: "1.5fr",
render: (c) => (
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
color: "#2563EB",
fontWeight: 600,
}}
>
{c.email}
</span>
),
},
{
key: "phone",
header: "رقم الجوال",
width: "1.5fr",
render: (c) => (
<span style={{ color: "var(--color-text-secondary)" }}>{c.phone}</span>
),
},
{
key: "addresses",
header: "العناوين",
width: "1fr",
render: (c) => (
<AddressBadge
count={c.addresses?.length ?? 0}
onClick={() => onManageAddresses(c)}
/>
),
},
{
key: "createdAt",
header: "تاريخ الانشاء",
width: "1fr",
align: "center",
render: (c) => (
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(c.createdAt).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
),
},
];
return ( return (
<div style={cardStyle}> <ReusableTable
{/* Column headers */} columns={columns}
<div data={clients}
dir="rtl" loading={loading}
style={{ search={search}
display: "grid", page={page}
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px", pages={pages}
...thStyle, onPageChange={onPageChange}
}} actionsWidth="80px"
> emptyIcon="👥"
<span>العميل</span> emptyTitle={
<span>البريد الإلكتروني</span> search ? `No results for "${search}"` : "No clients to display."
<span>الهاتف</span> }
<span>العناوين</span> emptyAction={
<span style={{ textAlign: "center" }}>تاريخ الإضافة</span> !search && (
<span style={{ textAlign: "center" }}>إجراءات</span> <Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
</div> Add first client
</Button>
{/* Loading */} )
{loading ? ( }
<div renderActions={(c) => (
style={{ <>
display: "flex", <IconBtn
alignItems: "center", title={`Edit ${c.name}`}
justifyContent: "center", color="#1D4ED8"
gap: 12, bg="#EFF6FF"
padding: "4rem 0", borderColor="#BFDBFE"
color: "var(--color-text-muted)", onClick={() => onEdit(c)}
}} >
> <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
<Spinner size="sm" className="text-blue-600" /> </IconBtn>
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> <IconBtn
</div> title={`Delete ${c.name}`}
) : clients.length === 0 ? ( color="#DC2626"
/* Empty state */ bg="#FEF2F2"
<EmptyState borderColor="#FECACA"
icon="👥" onClick={() => onDelete(c)}
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء لعرضهم."} >
action={ <i className="ti ti-trash" style={{ fontSize: 14 }} aria-hidden="true" />
!search && ( </IconBtn>
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}> </>
أضف أول عميل )}
</Button> renderExpanded={(c) => (
) <ClientDetailPanel
} client={c}
onGoAddresses={() => onManageAddresses(c)}
onEdit={() => onEdit(c)}
/> />
) : (
/* Data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{clients.map((c, i) => {
const isExpanded = expandedId === c.id;
return (
<li key={c.id}>
{/* ── Main row ── */}
<div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
aria-label={`${c.name} — انقر لعرض التفاصيل`}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleRowClick(c);
}}
onClick={() => handleRowClick(c)}
style={{
display: "grid",
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
alignItems: "center",
gap: "0.5rem",
padding: "0.875rem 1.5rem",
borderBottom: isExpanded
? "none"
: "1px solid var(--color-border)",
/* Highlight the expanded row with a brand-tinted background */
background: isExpanded
? "#EFF6FF"
: i % 2 !== 0
? "var(--color-surface-muted)"
: "transparent",
/* Left accent border when expanded (RTL: border-right in visual) */
borderRight: isExpanded
? "3px solid var(--color-brand-600)"
: "3px solid transparent",
fontSize: 13,
cursor: "pointer",
transition: "background 120ms, border-color 120ms",
outline: "none",
}}
>
{/* Name */}
<div>
<p
style={{
fontWeight: 600,
color: "var(--color-text-primary)",
margin: 0,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
{c.name}
{/* Chevron indicator — rotates when expanded */}
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
style={{
flexShrink: 0,
color: "var(--color-text-muted)",
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
transition: "transform 200ms",
}}
>
<path d="M9 18l6-6-6-6" />
</svg>
</p>
{c.taxId && (
<p
style={{
marginTop: 2,
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--color-text-muted)",
}}
>
{c.taxId}
</p>
)}
</div>
{/* Email */}
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
color: "#2563EB",
fontWeight: 600,
}}
>
{c.email}
</span>
{/* Phone */}
<span style={{ color: "var(--color-text-secondary)" }}>
{c.phone}
</span>
{/*
* Address count badge — clicking it navigates directly to
* /dashboard/clients/[clientId]/addresses (bypasses the detail panel).
*/}
<AddressBadge
count={c.addresses?.length ?? 0}
onClick={() => onManageAddresses(c)}
/>
{/* Created at */}
<span
style={{
textAlign: "center",
fontSize: 11,
color: "var(--color-text-muted)",
}}
>
{new Date(c.createdAt).toLocaleDateString("ar-SA", {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
{/* Row action buttons */}
<div
style={{
display: "flex",
justifyContent: "center",
gap: 6,
}}
onClick={(e) => e.stopPropagation()}
>
<IconBtn
title={`تعديل ${c.name}`}
color="#1D4ED8"
bg="#EFF6FF"
borderColor="#BFDBFE"
onClick={() => onEdit(c)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</IconBtn>
<IconBtn
title={`حذف ${c.name}`}
color="#DC2626"
bg="#FEF2F2"
borderColor="#FECACA"
onClick={() => onDelete(c)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</IconBtn>
</div>
</div>
{/*
* ── Expandable detail panel ──
* Rendered inline below the row when isExpanded is true.
* Contains full client metadata + "العناوين" navigation button.
*
* TEST: Click any row → panel slides in with client details.
* Click "العناوين" → router.push to /dashboard/clients/[id]/addresses.
* Click same row again → panel collapses.
*/}
{isExpanded && (
<ClientDetailPanel
client={c}
onGoAddresses={() => onManageAddresses(c)}
onEdit={() => onEdit(c)}
/>
)}
</li>
);
})}
</ul>
)} )}
/>
{/* Pagination */}
{pages > 1 && (
<div
dir="rtl"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderTop: "1px solid var(--color-border)",
padding: "0.875rem 1.5rem",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة{" "}
<strong style={{ color: "var(--color-text-primary)" }}>{page}</strong>{" "}
من{" "}
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{
label: "السابق",
action: () => onPageChange(Math.max(1, page - 1)),
disabled: page === 1,
},
{
label: "التالي",
action: () => onPageChange(Math.min(pages, page + 1)),
disabled: page === pages,
},
].map((btn) => (
<Button
key={btn.label}
type="button"
variant="secondary"
size="sm"
onClick={btn.action}
disabled={btn.disabled}
>
{btn.label}
</Button>
))}
</div>
</div>
)}
</div>
); );
} }

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

@@ -0,0 +1,5 @@
// Re-export the canonical implementation so existing imports keep working.
export { Toast } from "@/src/Components/UI";
export type { ToastNotification as Notification } from "@/src/Components/UI";

View File

@@ -1,112 +1,76 @@
"use client"; "use client";
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../../UI"; // src/Components/Client/archive/ArchivedClientTable.tsx
// MIGRATED to ReusableTable + ActionButtons. View-only, no row click in
// the original.
import { Badge, ReusableTable, ActionButtons } from "../../UI";
import type { TableColumn } from "@/src/types/models";
import type { ArchivedClient } from "@/src/types/client"; import type { ArchivedClient } from "@/src/types/client";
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Props ────────────────────────────────────────────────────────────────────
interface ArchivedClientTableProps { interface ArchivedClientTableProps {
clients: ArchivedClient[]; clients: ArchivedClient[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onView: (client: ArchivedClient) => void; onView: (client: ArchivedClient) => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ───────────────────────────────────────────────────────────────
export function ArchivedClientTable({ clients, loading, search, page, pages, onView, onPageChange }: ArchivedClientTableProps) { export function ArchivedClientTable({ clients, loading, search, page, pages, onView, onPageChange }: ArchivedClientTableProps) {
const columns: TableColumn<ArchivedClient>[] = [
{
key: "name",
header: "اسم العميل",
width: "2fr",
render: (c) => (
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{c.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{c.phone}</p>
</div>
),
},
{
key: "email",
header: "البريد الالكتروني",
width: "1.5fr",
render: (c) => <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{c.email}</span>,
},
{
key: "status",
header: "الحالة",
width: "1fr",
render: (c) => <Badge label={c.isActive ? "Active" : "Disabled"} color={c.isActive ? "green" : "red"} />,
},
{
key: "createdAt",
header: "تاريخ الانشاء",
width: "1fr",
align: "center",
render: (c) => new Date(c.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
},
{
key: "updatedAt",
header: "تاريخ التعديل",
width: "1fr",
align: "center",
render: (c) => new Date(c.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
},
];
return ( return (
<div style={cardStyle}> <ReusableTable
{/* column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> data={clients}
<span>العميل</span> loading={loading}
<span>البريد الإلكتروني</span> search={search}
<span>الحالة</span> page={page}
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> pages={pages}
<span style={{ textAlign: "center" }}>آخر تحديث</span> onPageChange={onPageChange}
<span style={{ textAlign: "center" }}>إجراءات</span> emptyIcon="👥"
</div> emptyDescription={search ? `No results for "${search}"` : "No archived clients."}
renderActions={(c) => <ActionButtons itemLabel={c.name} onView={() => onView(c)} />}
{/* loading state */} />
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : clients.length === 0 ? (
/* empty state */
<EmptyState
icon="👥"
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء في الأرشيف."}
/>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{clients.map((c, i) => (
<li key={c.id} style={{
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{c.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{c.phone}</p>
</div>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{c.email}</span>
<Badge label={c.isActive ? "نشط" : "معطل"} color={c.isActive ? "green" : "red"} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(c.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(c.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view only — delete/restore not implemented yet, matches archived users */}
<IconBtn title={`عرض ${c.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(c)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)}
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}>
{btn.label}
</Button>
))}
</div>
</div>
)}
</div>
); );
} }

View File

@@ -42,9 +42,7 @@ export function ArchivedClientsModal({ onClose }: ArchivedClientsModalProps) {
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
dir="rtl" dir="rtl"
icon={ icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
} }
/> />
</div> </div>

View File

@@ -1,5 +1,5 @@
// src/Components/Client/index.ts // src/Components/Client/index.ts
// CHANGE: removed DeleteConfirmModal export (Issue 1). // CHANGE: removed DeleteConfirmModal export (Issue 1).
export { ClientFormModal } from "./Clientformmodal"; export { ClientFormModal } from "./Clientformmodal";
export { ClientTable } from "./Clienttable"; export { ClientTable } from "./ClientTable";
export { AddressFormModal } from "../Client_Adress/Addressformmodal"; export { AddressFormModal } from "../Client_Adress/Addressformmodal";

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,19 +1,12 @@
"use client"; "use client";
import { Badge, EmptyState, IconBtn, Spinner } from "../../UI";
import { Badge, IconBtn, ReusableTable } from "../../UI";
import type { TableColumn } from "@/src/types/models";
import type { ArchivedClientAddress } from "@/src/types/client_adresses"; import type { ArchivedClientAddress } from "@/src/types/client_adresses";
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = { type Row = ArchivedClientAddress & { id: string };
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Props ──────────────────────────────────────────────────────────────────── // ── Props ────────────────────────────────────────────────────────────────────
interface ArchivedClientAddressesTableProps { interface ArchivedClientAddressesTableProps {
@@ -25,73 +18,87 @@ interface ArchivedClientAddressesTableProps {
// ── main table — no pagination footer: the endpoint returns the full set ───── // ── main table — no pagination footer: the endpoint returns the full set ─────
export function ArchivedClientAddressesTable({ addresses, loading, search, onView }: ArchivedClientAddressesTableProps) { export function ArchivedClientAddressesTable({ addresses, loading, search, onView }: ArchivedClientAddressesTableProps) {
return ( const rows: Row[] = addresses.map((a) => ({ ...a, id: a._id }));
<div style={cardStyle}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "1.5fr 2fr 1.2fr 1fr 1fr 100px", ...thStyle }}>
<span>النوع</span>
<span>العنوان</span>
<span>جهة الاتصال</span>
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* loading state */} const columns: TableColumn<Row>[] = [
{loading ? ( {
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> key: "type",
<Spinner size="sm" className="text-blue-600" /> header: "النوع",
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> width: "1.5fr",
render: (a) => (
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{a.label}</p>
{a.branchName && (
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{a.branchName}</p>
)}
</div> </div>
) : addresses.length === 0 ? ( ),
/* empty state */ },
<EmptyState {
icon="📍" key: "address",
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."} header: "العنوان",
width: "2fr",
render: (a) => (
<span style={{ color: "var(--color-text-secondary)" }}>
{a.details.street}، {a.details.city}
</span>
),
},
{
key: "contact",
header: "جهة الاتصال",
width: "1.2fr",
render: (a) => (
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
{a.contactPerson?.name ?? "—"}
</span>
),
},
{
key: "status",
header: "الحالة",
width: "1fr",
render: (a) => (
<Badge
label={a.isValidated ? "موثّق" : "غير موثّق"}
color={a.isValidated ? "green" : "slate"}
/> />
) : ( ),
/* data rows */ },
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> {
{addresses.map((a, i) => ( key: "createdAt",
<li key={a._id} style={{ header: "تاريخ الإنشاء",
display: "grid", gridTemplateColumns: "1.5fr 2fr 1.2fr 1fr 1fr 100px", width: "1fr",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem", render: (a) => (
borderBottom: "1px solid var(--color-border)", <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", {new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
fontSize: 13, </span>
}}> ),
<div> },
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{a.label}</p> ];
{a.branchName && (
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{a.branchName}</p> return (
)} <ReusableTable
</div> columns={columns}
<span style={{ color: "var(--color-text-secondary)" }}> data={rows}
{a.details.street}، {a.details.city} loading={loading}
</span> search={search}
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}> page={1}
{a.contactPerson?.name ?? "—"} pages={1}
</span> onPageChange={() => {}}
<Badge actionsWidth="100px"
label={a.isValidated ? "موثّق" : "غير موثّق"} onRowClick={onView}
color={a.isValidated ? "green" : "slate"} emptyDescription={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
/> renderActions={(a) => (
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}> // view only — matches archived users/clients: no delete/restore yet
{new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} <IconBtn
</span> title={`عرض ${a.label}`}
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}> color="#059669" bg="#ECFDF5" borderColor="#A7F3D0"
{/* view only — matches archived users/clients: no delete/restore yet */} onClick={() => onView(a)}
<IconBtn title={`عرض ${a.label}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(a)}> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> </IconBtn>
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)} )}
</div> />
); );
} }

View File

@@ -46,9 +46,7 @@ export function ArchivedClientsAddressesModal({ onClose, clientId }: ArchivedCli
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
dir="rtl" dir="rtl"
icon={ icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
} }
/> />
</div> </div>

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

@@ -120,18 +120,7 @@ function PhotoCard({ url, label }: { url?: string | null; label: string }) {
onError={() => setImgError(true)} onError={() => setImgError(true)}
/> />
) : ( ) : (
<svg <i className="ti ti-photo" style={{ fontSize: 28, color: "var(--color-text-hint)" }} aria-hidden="true" />
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-text-hint)"
strokeWidth="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
)} )}
</div> </div>
{url && !imgError && ( {url && !imgError && (
@@ -456,10 +445,7 @@ export function DriverDetailPanel({
</Button> </Button>
<Button type="button" variant="secondary" onClick={() => onEdit(driver)}> <Button type="button" variant="secondary" onClick={() => onEdit(driver)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
تعديل تعديل
</Button> </Button>

View File

@@ -6,7 +6,11 @@ import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, FileInput, Input, Modal, Select } from "../UI"; import { Alert, Button, FileInput, Input, Modal, Select } from "../UI";
import { get } from "@/src/services/api"; import { get } from "@/src/services/api";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { createDriverSchema, updateDriverSchema } from "@/src/validations/driver.validator"; import {
createDriverSchema,
updateDriverSchema,
} from "@/src/validations/driver.validator";
import { useEditFormSync } from "@/src/hooks/useEditFormSync";
import type { import type {
Driver, Driver,
CreateDriverPayload, CreateDriverPayload,
@@ -105,7 +109,9 @@ export function DriverFormModal({
// have structurally different required fields (union type yupResolver's // have structurally different required fields (union type yupResolver's
// single-schema signature won't accept), and yup's inferred type for // single-schema signature won't accept), and yup's inferred type for
// .optional() fields is structurally incompatible with DriverFormValues. // .optional() fields is structurally incompatible with DriverFormValues.
resolver: yupResolver((isNew ? createDriverSchema : updateDriverSchema) as any) as any, resolver: yupResolver(
(isNew ? createDriverSchema : updateDriverSchema) as any,
) as any,
defaultValues: { defaultValues: {
name: editDriver?.name ?? "", name: editDriver?.name ?? "",
phone: editDriver?.phone ?? "", phone: editDriver?.phone ?? "",
@@ -113,9 +119,12 @@ export function DriverFormModal({
address: editDriver?.address ?? "", address: editDriver?.address ?? "",
nationality: editDriver?.nationality ?? "", nationality: editDriver?.nationality ?? "",
nationalIdType: editDriver?.nationalIdType ?? "", nationalIdType: editDriver?.nationalIdType ?? "",
nationalId: (editDriver as Driver & { nationalId?: string })?.nationalId ?? "", nationalId:
(editDriver as Driver & { nationalId?: string })?.nationalId ?? "",
nationalIdExpiry: nationalIdExpiry:
(editDriver as Driver & { nationalIdExpiry?: string })?.nationalIdExpiry?.slice(0, 10) ?? "", (
editDriver as Driver & { nationalIdExpiry?: string }
)?.nationalIdExpiry?.slice(0, 10) ?? "",
gosiNumber: editDriver?.gosiNumber ?? "", gosiNumber: editDriver?.gosiNumber ?? "",
licenseNumber: editDriver?.licenseNumber ?? "", licenseNumber: editDriver?.licenseNumber ?? "",
licenseType: editDriver?.licenseType ?? "", licenseType: editDriver?.licenseType ?? "",
@@ -135,29 +144,30 @@ export function DriverFormModal({
// ── Local branches (auto-fetched if prop is empty) ───────────────────────── // ── Local branches (auto-fetched if prop is empty) ─────────────────────────
const [branches, setBranches] = useState<Branch[]>(branchesProp); const [branches, setBranches] = useState<Branch[]>(branchesProp);
useEffect(() => { useEffect(() => {
// defaultValues.branchId was applied before this list existed, so the
// <select> had no matching <option> yet and silently fell back to "" —
// reapply the saved id now that the option actually exists in the DOM.
const savedBranchId = (editDriver as Driver & { branchId?: string })?.branchId;
if (branchesProp.length > 0) { if (branchesProp.length > 0) {
queueMicrotask(() => { queueMicrotask(() => setBranches(branchesProp));
setBranches(branchesProp);
if (savedBranchId) setValue("branchId", savedBranchId);
});
return; return;
} }
const token = getStoredToken(); const token = getStoredToken();
get<{ data: { data: Branch[] } }>("branches?limit=100", token) get<{ data: { data: Branch[] } }>("branches?limit=100", token)
.then((res) => { .then((res) => {
const list = (res as unknown as { data: { data: Branch[] } }).data?.data ?? []; const list =
(res as unknown as { data: { data: Branch[] } }).data?.data ?? [];
setBranches(list); setBranches(list);
if (savedBranchId) setValue("branchId", savedBranchId);
}) })
.catch(() => { .catch(() => {
/* silently ignore */ /* silently ignore */
}); });
}, [branchesProp, editDriver, setValue]); }, [branchesProp]);
// CHANGE: replaced the manual `if (savedBranchId) setValue("branchId", ...)`
// calls above with useEditFormSync — same fix, shared implementation.
useEditFormSync(
setValue,
"branchId",
(editDriver as Driver & { branchId?: string })?.branchId,
branches,
);
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");
@@ -171,20 +181,27 @@ export function DriverFormModal({
const submitHandler = useCallback( const submitHandler = useCallback(
async (data: DriverFormValues) => { async (data: DriverFormValues) => {
const payload: Record<string, unknown> = { name: data.name, phone: data.phone }; const payload: Record<string, unknown> = {
name: data.name,
phone: data.phone,
};
if (data.email) payload.email = data.email; if (data.email) payload.email = data.email;
if (data.address) payload.address = data.address; if (data.address) payload.address = data.address;
if (data.nationality) payload.nationality = data.nationality; if (data.nationality) payload.nationality = data.nationality;
if (data.nationalIdType) payload.nationalIdType = data.nationalIdType; if (data.nationalIdType) payload.nationalIdType = data.nationalIdType;
if (data.nationalId) payload.nationalId = data.nationalId; if (data.nationalId) payload.nationalId = data.nationalId;
if (data.nationalIdExpiry) payload.nationalIdExpiry = toIsoDateTime(data.nationalIdExpiry); if (data.nationalIdExpiry)
payload.nationalIdExpiry = toIsoDateTime(data.nationalIdExpiry);
if (data.gosiNumber) payload.gosiNumber = data.gosiNumber; if (data.gosiNumber) payload.gosiNumber = data.gosiNumber;
if (data.licenseNumber) payload.licenseNumber = data.licenseNumber; if (data.licenseNumber) payload.licenseNumber = data.licenseNumber;
if (data.licenseType) payload.licenseType = data.licenseType; if (data.licenseType) payload.licenseType = data.licenseType;
if (data.licenseExpiry) payload.licenseExpiry = toIsoDateTime(data.licenseExpiry); if (data.licenseExpiry)
if (data.driverCardNumber) payload.driverCardNumber = data.driverCardNumber; payload.licenseExpiry = toIsoDateTime(data.licenseExpiry);
if (data.driverCardNumber)
payload.driverCardNumber = data.driverCardNumber;
if (data.driverCardType) payload.driverCardType = data.driverCardType; if (data.driverCardType) payload.driverCardType = data.driverCardType;
if (data.driverCardExpiry) payload.driverCardExpiry = toIsoDateTime(data.driverCardExpiry); if (data.driverCardExpiry)
payload.driverCardExpiry = toIsoDateTime(data.driverCardExpiry);
if (data.driverType) payload.driverType = data.driverType; if (data.driverType) payload.driverType = data.driverType;
if (data.branchId) payload.branchId = data.branchId; if (data.branchId) payload.branchId = data.branchId;
if (!isNew) payload.status = data.status; if (!isNew) payload.status = data.status;
@@ -196,15 +213,23 @@ export function DriverFormModal({
setApiError(""); setApiError("");
try { try {
const ok = await onSubmit(payload as unknown as CreateDriverPayload, isNew); const ok = await onSubmit(
payload as unknown as CreateDriverPayload,
isNew,
);
if (ok) { if (ok) {
onClose(); onClose();
} else { } else {
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." }); setError("name", {
message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.",
});
setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."); setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."; const message =
err instanceof Error
? err.message
: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.";
setError("name", { message }); setError("name", { message });
setApiError(message); setApiError(message);
} }
@@ -220,10 +245,15 @@ export function DriverFormModal({
onClose={onClose} onClose={onClose}
size="lg" size="lg"
subtitle={isNew ? "إضافة سائق" : "تعديل سائق"} subtitle={isNew ? "إضافة سائق" : "تعديل سائق"}
title={isNew ? "سائق جديد" : editDriver?.name ?? ""} title={isNew ? "سائق جديد" : (editDriver?.name ?? "")}
footer={ footer={
<> <>
<Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}> <Button
variant="secondary"
type="button"
onClick={onClose}
disabled={isSubmitting}
>
إلغاء إلغاء
</Button> </Button>
<Button type="submit" form={FORM_ID} loading={isSubmitting}> <Button type="submit" form={FORM_ID} loading={isSubmitting}>
@@ -240,7 +270,11 @@ export function DriverFormModal({
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
{errors.name?.type === "manual" && ( {errors.name?.type === "manual" && (
<Alert type="error" message={errors.name.message ?? ""} onClose={() => setApiError("")} /> <Alert
type="error"
message={errors.name.message ?? ""}
onClose={() => setApiError("")}
/>
)} )}
{/* ── Section: Personal Info ── */} {/* ── Section: Personal Info ── */}
@@ -252,7 +286,9 @@ export function DriverFormModal({
placeholder="محمد عبدالله" placeholder="محمد عبدالله"
dir="rtl" dir="rtl"
autoComplete="off" autoComplete="off"
error={errors.name?.type !== "manual" ? errors.name?.message : undefined} error={
errors.name?.type !== "manual" ? errors.name?.message : undefined
}
{...register("name")} {...register("name")}
/> />
@@ -404,7 +440,9 @@ export function DriverFormModal({
> >
<option value="">اختر الفرع</option> <option value="">اختر الفرع</option>
{branches.map((b) => ( {branches.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option> <option key={b.id} value={b.id}>
{b.name}
</option>
))} ))}
</Select> </Select>
@@ -429,7 +467,14 @@ export function DriverFormModal({
{/* ── Section: Photos ── */} {/* ── Section: Photos ── */}
<p style={sectionHeadingStyle}>الصور والمستندات</p> <p style={sectionHeadingStyle}>الصور والمستندات</p>
<p style={{ fontSize: 11, color: "var(--color-text-hint)", margin: "0.25rem 0 0", fontWeight: 400 }}> <p
style={{
fontSize: 11,
color: "var(--color-text-hint)",
margin: "0.25rem 0 0",
fontWeight: 400,
}}
>
جميع حقول الصور اختيارية جميع حقول الصور اختيارية
</p> </p>
@@ -440,25 +485,37 @@ export function DriverFormModal({
name="photo" name="photo"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<FileInput label="صورة السائق" current={field.value} onChange={field.onChange} /> <FileInput
label="صورة السائق"
current={field.value}
onChange={field.onChange}
/>
)} )}
/> />
<Controller <Controller
name="nationalPhoto" name="nationalPhoto"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<FileInput label="صورة الهوية" current={field.value} onChange={field.onChange} /> <FileInput
label="صورة الهوية"
current={field.value}
onChange={field.onChange}
/>
)} )}
/> />
<Controller <Controller
name="driverCardPhoto" name="driverCardPhoto"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<FileInput label="صورة البطاقة" current={field.value} onChange={field.onChange} /> <FileInput
label="صورة البطاقة"
current={field.value}
onChange={field.onChange}
/>
)} )}
/> />
</div> </div>
</form> </form>
</Modal> </Modal>
); );
} }

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

@@ -79,18 +79,7 @@ export function PhotoCard({
/> />
</> </>
) : ( ) : (
<svg <i className="ti ti-photo" style={{ fontSize: 32, color: "var(--color-text-hint)" }} aria-hidden="true" />
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-text-hint)"
strokeWidth="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
)} )}
</div> </div>
{url && !imgError && ( {url && !imgError && (
@@ -110,4 +99,4 @@ export function PhotoCard({
)} )}
</div> </div>
); );
} }

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,142 +1,86 @@
"use client"; "use client";
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
import { ReusableTable, ActionButtons } from "../../UI";
import { DRIVER_STATUS_MAP } from "@/src/types/driver"; import { DRIVER_STATUS_MAP } from "@/src/types/driver";
import type { TableColumn } from "@/src/types/models";
import type { ArchivedDriver } from "@/src/types/driver"; import type { ArchivedDriver } from "@/src/types/driver";
// ── status badge ─────────────────────────────────────────────────────────────
// Kept custom rather than swapped for <Badge/>: colors come dynamically from
// DRIVER_STATUS_MAP (multiple statuses, each with its own color+dot), which
// the shared Badge component's fixed palette doesn't cover.
function StatusBadge({ status }: { status: ArchivedDriver["status"] }) {
const cfg = DRIVER_STATUS_MAP[status] ?? DRIVER_STATUS_MAP.Inactive;
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${cfg.border}`, background: cfg.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: cfg.color }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: cfg.dot }} />
{cfg.label}
</span>
);
}
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
const ROW_GRID_COLUMNS = "2fr 1.5fr 1fr 1fr 1fr 100px";
// ── Props ────────────────────────────────────────────────────────────────────
interface ArchivedDriverTableProps { interface ArchivedDriverTableProps {
drivers: ArchivedDriver[]; drivers: ArchivedDriver[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onView: (driver: ArchivedDriver) => void; onView: (driver: ArchivedDriver) => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
/**
* Renders the paginated table of archived drivers, including loading,
* empty, and error-free states. Mirrors ArchivedUserTable for consistency.
*/
export function ArchivedDriverTable({ drivers, loading, search, page, pages, onView, onPageChange }: ArchivedDriverTableProps) { export function ArchivedDriverTable({ drivers, loading, search, page, pages, onView, onPageChange }: ArchivedDriverTableProps) {
return ( const columns: TableColumn<ArchivedDriver>[] = [
<div style={cardStyle}> {
{/* column headers */} key: "name",
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, ...thStyle }}> header: "الاسم",
<span>الاسم</span> width: "2fr",
<span>اسم المستخدم</span> render: (d) => (
<span>الحالة</span> <div>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> <p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p>
<span style={{ textAlign: "center" }}>آخر تحديث</span> <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* loading state */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div> </div>
) : drivers.length === 0 ? ( ),
/* empty state */ },
<EmptyState {
icon="🗄️" key: "userName",
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد سائقون في الأرشيف."} header: "اسم السائق",
/> width: "1.5fr",
) : ( render: (d) => (
/* data rows */ <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{d.userName ?? "—"}</span>
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> ),
{drivers.map((d, i) => ( },
<li key={d.id} onClick={() => onView(d)} style={{ {
display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, key: "status",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem", header: "الحالة",
borderBottom: "1px solid var(--color-border)", width: "1fr",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", render: (d) => {
fontSize: 13, cursor: "pointer", const s = DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive;
}}> return (
<div> <span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: s.color }}>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p> <span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p> {s.label}
</div>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{d.userName ?? "—"}</span>
<StatusBadge status={d.status} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(d.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(d.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view only — delete/restore not implemented yet, same as users */}
<IconBtn title={`عرض ${d.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(d)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)}
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span> </span>
<div style={{ display: "flex", gap: "0.5rem" }}> );
<Button },
type="button" },
variant="secondary" {
size="sm" key: "createdAt",
onClick={() => onPageChange(Math.max(1, page - 1))} header: "تاريخ الانشاء",
disabled={page === 1} width: "1fr",
> align: "center",
السابق render: (d) => new Date(d.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
</Button> },
<Button {
type="button" key: "updatedAt",
variant="secondary" header: "تاريخ التعديل",
size="sm" width: "1fr",
onClick={() => onPageChange(Math.min(pages, page + 1))} align: "center",
disabled={page === pages} render: (d) => new Date(d.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
> },
التالي ];
</Button>
</div> return (
</div> <ReusableTable
)} columns={columns}
</div> data={drivers}
loading={loading}
search={search}
page={page}
pages={pages}
onPageChange={onPageChange}
onRowClick={onView}
emptyIcon="🗄️"
emptyDescription={search ? `No results for "${search}"` : "No archived drivers."}
renderActions={(d) => <ActionButtons itemLabel={d.name} onView={() => onView(d)} />}
/>
); );
} }

View File

@@ -11,18 +11,7 @@ interface ArchivedDriversProps {
} }
const searchIcon = ( const searchIcon = (
<svg <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
); );
/** /**

View File

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,551 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Spinner, Alert } from "../UI";
import { orderService } from "@/src/services/order.service";
import type { Order, OrderStatus, UpdateOrderStatusPayload } from "@/src/types/order";
const ORDER_STATUS_MAP: Record<OrderStatus, { label: string; color: string; bg: string; border: string; dot: string }> = {
Created: { label: "تم الإنشاء", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE", dot: "#3B82F6" },
Assigned: { label: "مُعيَّن", color: "#5B21B6", bg: "#F5F3FF", border: "#DDD6FE", dot: "#8B5CF6" },
InTransit: { label: "قيد التوصيل", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" },
Delivered: { label: "تم التسليم", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" },
Returned: { label: "مُرتجع", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA", dot: "#DC2626" },
Cancelled: { label: "ملغي", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0", dot: "#94A3B8" },
};
const PAY_STATUS_MAP: Record<string, { label: string; color: string }> = {
Pending: { label: "معلَّق", color: "#D97706" },
Paid: { label: "مدفوع", color: "#16A34A" },
Failed: { label: "فشل", color: "#DC2626" },
Refunded: { label: "مُسترجع", color: "#64748B" },
};
const PAY_METHOD_LABEL: Record<string, string> = {
Cash: "نقداً",
Card: "بطاقة",
Prepaid: "مدفوع مسبقاً",
};
function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric",
month: "long",
day: "numeric",
});
}
function fmtAmount(n?: number | string | null): string {
if (n == null || n === "") return "—";
const num = Number(n);
if (isNaN(num)) return "—";
return `${num.toFixed(2)} ر.س`;
}
function DetailRow({
label,
value,
mono = false,
warn = false,
}: {
label: string;
value: string;
mono?: boolean;
warn?: boolean;
}) {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
padding: "0.55rem 0",
borderBottom: "1px solid var(--color-border)",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>
{label}
</span>
<span
style={{
fontSize: 13,
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
color: warn ? "#D97706" : "var(--color-text-primary)",
fontWeight: warn ? 600 : 400,
maxWidth: "60%",
textAlign: "left",
wordBreak: "break-word",
}}
>
{warn && value !== "—" ? "⚠ " : ""}
{value}
</span>
</div>
);
}
function SectionHeading({ title }: { title: string }) {
return (
<p
style={{
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: "1.25rem 0 0.5rem",
}}
>
{title}
</p>
);
}
interface OrderDetailPanelProps {
orderId: string;
onClose: () => void;
onEdit: (order: Order) => void;
onDelete: (order: Order) => void;
onStatusChanged?: (order: Order) => void;
}
export function OrderDetailPanel({
orderId,
onClose,
onEdit,
onDelete,
onStatusChanged,
}: OrderDetailPanelProps) {
const [order, setOrder] = useState<Order | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statusDraft, setStatusDraft] = useState<OrderStatus | "">("");
const [statusReason, setStatusReason] = useState("");
const [updatingStatus, setUpdatingStatus] = useState(false);
const [statusError, setStatusError] = useState<string | null>(null);
const loadOrder = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await orderService.getById(orderId);
setOrder(data);
setStatusDraft(data.currentStatus);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات الطلب.");
} finally {
setLoading(false);
}
}, [orderId]);
useEffect(() => { queueMicrotask(loadOrder); }, [loadOrder]);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
const handleStatusUpdate = useCallback(async () => {
if (!order || !statusDraft || statusDraft === order.currentStatus) return;
setUpdatingStatus(true);
setStatusError(null);
try {
const payload: UpdateOrderStatusPayload = { status: statusDraft };
if (statusReason) payload.reason = statusReason;
const updated = await orderService.updateStatus(order.id, payload);
setOrder(updated);
setStatusReason("");
onStatusChanged?.(updated);
} catch (err: unknown) {
setStatusError(err instanceof Error ? err.message : "تعذّر تحديث حالة الطلب.");
} finally {
setUpdatingStatus(false);
}
}, [order, statusDraft, statusReason, onStatusChanged]);
const statusConfig = order ? ORDER_STATUS_MAP[order.currentStatus] : null;
const payStatus = order?.paymentStatus ? PAY_STATUS_MAP[order.paymentStatus] : null;
return (
<>
<div
onClick={onClose}
style={{
position: "fixed",
inset: 0,
zIndex: 40,
background: "rgba(15,23,42,0.45)",
backdropFilter: "blur(2px)",
}}
/>
<aside
aria-label="تفاصيل الطلب"
style={{
position: "fixed",
top: 0,
left: 0,
bottom: 0,
zIndex: 50,
width: "min(520px, 100vw)",
background: "var(--color-surface)",
borderRight: "1px solid var(--color-border)",
display: "flex",
flexDirection: "column",
boxShadow: "8px 0 40px rgba(0,0,0,.18)",
overflowY: "hidden",
}}
>
<div
style={{
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
flexShrink: 0,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div
style={{
width: 56,
height: 56,
borderRadius: "50%",
flexShrink: 0,
border: "2px solid var(--color-brand-200)",
background: "var(--color-surface-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-brand-600)" strokeWidth="2">
<rect x="2" y="7" width="20" height="14" rx="2"/>
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
</svg>
</div>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
ملف الطلب
</p>
{order && (
<h2 style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "3px 0 0", fontFamily: "var(--font-mono)" }}>
{order.shipmentNumber}
</h2>
)}
{order?.trip?.tripNumber && (
<p style={{ fontSize: 11, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)", margin: "2px 0 0" }}>
رحلة: {order.trip.tripNumber}
</p>
)}
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label="إغلاق"
style={{
width: 34,
height: 34,
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer",
fontSize: 18,
color: "var(--color-text-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
×
</button>
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "1.25rem 1.5rem" }}>
{loading && (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>جارٍ التحميل</span>
</div>
)}
{error && (
<div style={{
borderRadius: "var(--radius-md)",
background: "#FEF2F2",
border: "1px solid #FECACA",
padding: "0.75rem 1rem",
fontSize: 13,
color: "#DC2626",
}}>
{error}
</div>
)}
{order && !loading && (
<>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1rem" }}>
{statusConfig && (
<span style={{
borderRadius: "var(--radius-full)",
border: `1px solid ${statusConfig.border}`,
background: statusConfig.bg,
padding: "0.3rem 0.875rem",
fontSize: 12,
fontWeight: 700,
color: statusConfig.color,
display: "inline-flex",
alignItems: "center",
gap: 6,
}}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: statusConfig.dot }} />
{statusConfig.label}
</span>
)}
{payStatus && (
<span style={{
borderRadius: "var(--radius-full)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
padding: "0.3rem 0.875rem",
fontSize: 12,
fontWeight: 700,
color: payStatus.color,
}}>
{payStatus.label}
</span>
)}
</div>
<SectionHeading title="بيانات المستلم" />
<DetailRow label="اسم المستلم" value={order.recipientName} />
<DetailRow label="رقم الجوال" value={order.recipientPhone} mono />
<DetailRow label="العميل" value={order.client?.name ?? "—"} />
<SectionHeading title="بيانات الشحنة" />
<DetailRow label="رقم الشحنة" value={order.shipmentNumber} mono />
<DetailRow label="نوع الشحنة" value={order.type ?? "—"} />
<DetailRow label="الكمية" value={String(order.quantity ?? "—")} />
<DetailRow label="الوزن" value={order.weight != null ? `${order.weight} كجم` : "—"} />
<DetailRow label="الرحلة" value={order.trip?.tripNumber ?? "—"} mono />
<SectionHeading title="بيانات الدفع" />
<DetailRow label="طريقة الدفع" value={order.paymentMethod ? PAY_METHOD_LABEL[order.paymentMethod] ?? order.paymentMethod : "—"} />
<DetailRow label="حالة الدفع" value={payStatus?.label ?? "—"} />
<DetailRow label="الإجمالي الفرعي" value={fmtAmount(order.subTotal)} mono />
<DetailRow label="ضريبة القيمة المضافة" value={fmtAmount(order.vatAmount)} mono />
<DetailRow label="الإجمالي" value={fmtAmount(order.totalPrice)} mono />
{order.deliveryAddress?.details && Object.values(order.deliveryAddress.details).some(Boolean) && (
<>
<SectionHeading title="عنوان التسليم" />
{order.deliveryAddress.details.city && <DetailRow label="المدينة" value={order.deliveryAddress.details.city} />}
{order.deliveryAddress.details.district && <DetailRow label="الحي" value={order.deliveryAddress.details.district} />}
{order.deliveryAddress.details.street && <DetailRow label="الشارع" value={order.deliveryAddress.details.street} />}
{order.deliveryAddress.details.buildingNo && <DetailRow label="رقم المبنى" value={order.deliveryAddress.details.buildingNo} mono />}
{order.deliveryAddress.details.unitNo && <DetailRow label="رقم الوحدة" value={order.deliveryAddress.details.unitNo} mono />}
{order.deliveryAddress.details.zipCode && <DetailRow label="الرمز البريدي" value={order.deliveryAddress.details.zipCode} mono />}
</>
)}
{!order.deliveryAddress?.details && order.deliveryAddressId && (
<>
<SectionHeading title="عنوان التسليم" />
<DetailRow label="معرّف العنوان" value={order.deliveryAddressId} mono />
</>
)}
{order.pickupAddress?.details && Object.values(order.pickupAddress.details).some(Boolean) && (
<>
<SectionHeading title="عنوان الاستلام" />
{order.pickupAddress.details.city && <DetailRow label="المدينة" value={order.pickupAddress.details.city} />}
{order.pickupAddress.details.district && <DetailRow label="الحي" value={order.pickupAddress.details.district} />}
{order.pickupAddress.details.street && <DetailRow label="الشارع" value={order.pickupAddress.details.street} />}
{order.pickupAddress.details.buildingNo && <DetailRow label="رقم المبنى" value={order.pickupAddress.details.buildingNo} mono />}
{order.pickupAddress.details.unitNo && <DetailRow label="رقم الوحدة" value={order.pickupAddress.details.unitNo} mono />}
{order.pickupAddress.details.zipCode && <DetailRow label="الرمز البريدي" value={order.pickupAddress.details.zipCode} mono />}
</>
)}
{!order.pickupAddress?.details && order.pickupAddressId && (
<>
<SectionHeading title="عنوان الاستلام" />
<DetailRow label="معرّف العنوان" value={order.pickupAddressId} mono />
</>
)}
<SectionHeading title="معلومات النظام" /> <DetailRow label="تاريخ الإنشاء" value={fmtDate(order.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(order.updatedAt)} />
<SectionHeading title="تحديث الحالة" />
{statusError && (
<Alert type="error" message={statusError} onClose={() => setStatusError(null)} />
)}
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
<select
value={statusDraft}
onChange={(e) => setStatusDraft(e.target.value as OrderStatus)}
dir="rtl"
style={{
height: 40, padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)",
fontFamily: "var(--font-sans)", cursor: "pointer",
}}
>
{Object.entries(ORDER_STATUS_MAP).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
<input
type="text"
value={statusReason}
onChange={(e) => setStatusReason(e.target.value)}
placeholder="سبب التغيير (اختياري)"
dir="rtl"
style={{
height: 40, padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)",
fontFamily: "var(--font-sans)",
}}
/>
<button
type="button"
onClick={handleStatusUpdate}
disabled={updatingStatus || !statusDraft || statusDraft === order.currentStatus}
style={{
height: 40,
borderRadius: "var(--radius-md)",
border: "none",
background: updatingStatus ? "var(--color-brand-400)" : "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: (updatingStatus || !statusDraft || statusDraft === order.currentStatus) ? "not-allowed" : "pointer",
opacity: (!statusDraft || statusDraft === order.currentStatus) ? 0.6 : 1,
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{updatingStatus && <Spinner size="sm" className="text-white" />}
{updatingStatus ? "جارٍ التحديث…" : "تحديث الحالة"}
</button>
</div>
{order.statusHistory && order.statusHistory.length > 0 && (
<>
<SectionHeading title="سجل الحالات" />
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{order.statusHistory.slice(0, 5).map((h) => {
const s = ORDER_STATUS_MAP[h.status] ?? ORDER_STATUS_MAP.Created;
return (
<div
key={h.id}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
borderRadius: "var(--radius-md)",
border: `1px solid ${s.border}`,
background: s.bg,
padding: "0.5rem 0.875rem",
}}
>
<div>
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>{s.label}</span>
{h.reason && (
<span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}>
{h.reason}
</span>
)}
</div>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
{fmtDate(h.createdAt)}
</span>
</div>
);
})}
</div>
</>
)}
</>
)}
</div>
{order && (
<div
style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex",
gap: "0.75rem",
flexShrink: 0,
}}
>
<button
type="button"
onClick={() => onDelete(order)}
style={{
height: 40, padding: "0 1rem",
borderRadius: "var(--radius-md)",
border: "1px solid #FECACA",
background: "#FEF2F2",
fontSize: 13, fontWeight: 700, color: "#DC2626",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
حذف
</button>
<button
type="button"
onClick={() => onEdit(order)}
style={{
height: 40, padding: "0 1rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-brand-200)",
background: "var(--color-brand-50, #EFF6FF)",
fontSize: 13, fontWeight: 700, color: "var(--color-brand-600)",
cursor: "pointer", fontFamily: "var(--font-sans)",
display: "flex", alignItems: "center", gap: 6,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
تعديل
</button>
<button
type="button"
onClick={onClose}
style={{
flex: 1, height: 40,
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إغلاق
</button>
</div>
)}
</aside>
</>
);
}
export { ORDER_STATUS_MAP, PAY_STATUS_MAP, PAY_METHOD_LABEL };

View File

@@ -222,10 +222,7 @@ export function OrderDetailPanel({
justifyContent: "center", justifyContent: "center",
}} }}
> >
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-brand-600)" strokeWidth="2"> <i className="ti ti-package" style={{ fontSize: 24, color: "var(--color-brand-600)" }} aria-hidden="true" />
<rect x="2" y="7" width="20" height="14" rx="2"/>
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
</svg>
</div> </div>
<div> <div>
@@ -460,10 +457,7 @@ export function OrderDetailPanel({
</Button> </Button>
<Button type="button" variant="secondary" onClick={() => onEdit(order)}> <Button type="button" variant="secondary" onClick={() => onEdit(order)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
تعديل تعديل
</Button> </Button>

View File

@@ -372,11 +372,8 @@ export function OrderFormModal({
))} ))}
</Select> </Select>
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}> <Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
<line x1="12" y1="5" x2="12" y2="19" /> إضافة عميل جديد
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إنشاء عميل جديد
</Link> </Link>
</div> </div>
@@ -418,10 +415,7 @@ export function OrderFormModal({
))} ))}
</Select> </Select>
<Link href="/dashboard/trips" style={createLinkStyle} tabIndex={-1}> <Link href="/dashboard/trips" style={createLinkStyle} tabIndex={-1}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إنشاء رحلة جديدة إنشاء رحلة جديدة
</Link> </Link>
</div> </div>

View File

@@ -1,60 +1,20 @@
"use client"; "use client";
import { Spinner } from "../UI"; import { ReusableTable, ActionButtons } from "../UI";
import type { Order } from "@/src/types/order";
import { ORDER_STATUS_MAP } from "./OrderDetailModel"; import { ORDER_STATUS_MAP } from "./OrderDetailModel";
import type { TableColumn } from "@/src/types/models";
// ── Helpers — copied verbatim from app/dashboard/drivers/page.tsx ─────── import type { Order } from "@/src/types/order";
function fmtDate(iso?: string | null): string { function fmtDate(iso?: string | null): string {
if (!iso) return "—"; if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", { return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
year: "numeric", month: "short", day: "numeric",
});
} }
function fmtAmount(n?: number | null): string { function fmtAmount(n?: number | null): string {
if (n == null) return "—"; if (n == null) return "—";
return `${n.toFixed(2)} ر.س`; return `${n.toFixed(2)} SAR`;
} }
// ── Styles — identical tokens/values to the Drivers page, per the
// "no new styles" constraint; only column widths differ to fit Order's
// field set (shipment / recipient / client / amount / status / actions).
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem",
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.2em",
color: "var(--color-text-muted)",
background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// Shared across header + rows — keep these in sync or columns will misalign.
const ROW_GRID_COLUMNS = "1.6fr 1.6fr 1.2fr 1.1fr 1fr 0.8fr";
const iconBtnBase: React.CSSProperties = {
width: 30,
height: 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-md)",
cursor: "pointer",
flexShrink: 0,
};
interface OrderTableProps { interface OrderTableProps {
orders: Order[]; orders: Order[];
loading: boolean; loading: boolean;
@@ -68,181 +28,86 @@ interface OrderTableProps {
} }
export function OrderTable({ export function OrderTable({
orders, orders, loading, search, page, pages,
loading, setPage, onRowClick, onEdit, onDelete,
search,
page,
pages,
setPage,
onRowClick,
onEdit,
onDelete,
}: OrderTableProps) { }: OrderTableProps) {
return ( const columns: TableColumn<Order>[] = [
<div style={cardStyle}> {
<div key: "shipmentNumber",
dir="rtl" header: "رقم الشحنة #",
style={{ width: "1.6fr",
display: "grid", render: (o) => (
gridTemplateColumns: ROW_GRID_COLUMNS, <div>
...thStyle, <p style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB", margin: 0 }}>{o.shipmentNumber}</p>
}} <p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(o.createdAt)}</p>
>
<span>رقم الشحنة</span>
<span>المستلم</span>
<span>العميل</span>
<span>المبلغ</span>
<span>الحالة</span>
<span>إجراءات</span>
</div>
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div> </div>
) : orders.length === 0 ? ( ),
<p style={{ textAlign: "center", padding: "4rem 0", fontSize: 13, color: "var(--color-text-muted)" }}> },
لا توجد نتائج {search && `لـ "${search}"`} {
</p> key: "recipient",
) : ( header: "المستلم",
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> width: "1.6fr",
{orders.map((o, i) => { render: (o) => (
const statusCfg = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created; <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
return ( <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
<li </div>
key={o.id} ),
onClick={() => onRowClick(o.id)} },
style={{ { key: "client", header: "العميل", width: "1.2fr", render: (o) => o.clientId ?? "—" },
display: "grid", {
gridTemplateColumns: ROW_GRID_COLUMNS, key: "amount",
alignItems: "center", gap: "0.5rem", header: "الكمية",
padding: "0.875rem 1.5rem", width: "1.1fr",
borderBottom: "1px solid var(--color-border)", render: (o) => (
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", <span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}>
fontSize: 13, {o.quantity ?? "—"}
cursor: "pointer", </span>
transition: "background 0.15s", ),
}} },
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--color-surface-hover, #F8FAFC)")} {
onMouseLeave={(e) => (e.currentTarget.style.background = i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent")} key: "status",
> header: "الحالة",
<div> width: "1fr",
<p style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB", margin: 0 }}>{o.shipmentNumber}</p> render: (o) => {
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(o.createdAt)}</p> const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
</div> return (
<div> <span style={{
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p> display: "inline-flex", alignItems: "center", gap: 5,
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p> borderRadius: "var(--radius-full)",
</div> border: `1px solid ${s.border}`,
<span style={{ color: "var(--color-text-secondary)" }}>{o.client?.name ?? "—"}</span> background: s.bg,
<span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}> padding: "0.2rem 0.625rem",
{fmtAmount(o.totalPrice)} fontSize: 11, fontWeight: 600, color: s.color,
</span> width: "fit-content",
<span style={{ }}>
display: "inline-flex", alignItems: "center", gap: 5, <span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot, flexShrink: 0 }} />
borderRadius: "var(--radius-full)", {s.label}
border: `1px solid ${statusCfg.border}`,
background: statusCfg.bg,
padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 600, color: statusCfg.color,
width: "fit-content",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusCfg.dot, flexShrink: 0 }} />
{statusCfg.label}
</span>
{/* ── Inline row actions: edit / delete ──
stopPropagation is required on both buttons so the
click doesn't bubble up to the <li> onClick and
open the detail panel as well. */}
<div style={{ display: "flex", gap: "0.4rem" }}>
<button
type="button"
aria-label="تعديل الطلب"
title="تعديل"
onClick={(e) => { e.stopPropagation(); onEdit(o); }}
style={{
...iconBtnBase,
border: "1px solid var(--color-brand-200)",
background: "var(--color-brand-50, #EFF6FF)",
color: "var(--color-brand-600)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
type="button"
aria-label="حذف الطلب"
title="حذف"
onClick={(e) => { e.stopPropagation(); onDelete(o); }}
style={{
...iconBtnBase,
border: "1px solid #FECACA",
background: "#FEF2F2",
color: "#DC2626",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
</div>
</li>
);
})}
</ul>
)}
{/* ── Pagination ── */}
{pages > 1 && (
<div
dir="rtl"
style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة{" "}
<strong style={{ color: "var(--color-text-primary)" }}>{page}</strong>{" "}
من{" "}
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span> </span>
<div style={{ display: "flex", gap: "0.5rem" }}> );
{[ },
{ label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 }, },
{ label: "التالي", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages }, ];
].map(btn => (
<button return (
key={btn.label} <ReusableTable
onClick={btn.action} columns={columns}
disabled={btn.disabled} data={orders}
style={{ loading={loading}
borderRadius: "var(--radius-lg)", search={search}
border: "1px solid var(--color-border)", page={page}
background: "var(--color-surface-muted)", pages={pages}
padding: "0.375rem 0.875rem", onPageChange={setPage}
fontSize: 12, color: "var(--color-text-secondary)", actionsWidth="0.8fr"
cursor: btn.disabled ? "not-allowed" : "pointer", onRowClick={(o) => onRowClick(o.id)}
opacity: btn.disabled ? 0.4 : 1, emptyDescription={search ? `No results for "${search}"` : undefined}
fontFamily: "var(--font-sans)", renderActions={(o) => (
}} <ActionButtons
> itemLabel={o.shipmentNumber}
{btn.label} onEdit={() => onEdit(o)}
</button> onDelete={() => onDelete(o)}
))} />
</div>
</div>
)} )}
</div> />
); );
} }

View File

@@ -1,132 +1,80 @@
"use client"; "use client";
import { Button, EmptyState, IconBtn, Spinner } from "../../UI"; import { ReusableTable, ActionButtons } from "../../UI";
import { ORDER_STATUS_MAP } from "../OrderDetailModel"; import { ORDER_STATUS_MAP } from "../OrderDetailModel";
import type { TableColumn } from "@/src/types/models";
import type { ArchivedOrder } from "@/src/types/order"; import type { ArchivedOrder } from "@/src/types/order";
function fmtAmount(n?: string | number | null): string { function fmtAmount(n?: string | number | null): string {
if (n == null) return "—"; if (n == null) return "—";
const num = typeof n === "string" ? parseFloat(n) : n; const num = typeof n === "string" ? parseFloat(n) : n;
if (Number.isNaN(num)) return "—"; if (Number.isNaN(num)) return "—";
return `${num.toFixed(2)} ر.س`; return `${num.toFixed(2)} SAR`;
} }
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
const ROW_GRID = "1.6fr 1.6fr 1fr 1fr 100px";
interface ArchivedOrderTableProps { interface ArchivedOrderTableProps {
orders: ArchivedOrder[]; orders: ArchivedOrder[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onView: (order: ArchivedOrder) => void; onView: (order: ArchivedOrder) => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
export function ArchivedOrderTable({ orders, loading, search, page, pages, onView, onPageChange }: ArchivedOrderTableProps) { export function ArchivedOrderTable({ orders, loading, search, page, pages, onView, onPageChange }: ArchivedOrderTableProps) {
return ( const columns: TableColumn<ArchivedOrder>[] = [
<div style={cardStyle}> {
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID, ...thStyle }}> key: "shipmentNumber",
<span>رقم الشحنة</span> header: "رقم الشحنة #",
<span>المستلم</span> width: "1.6fr",
<span>المبلغ</span> render: (o) => <span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB" }}>{o.shipmentNumber}</span>,
<span>الحالة</span> },
<span style={{ textAlign: "center" }}>إجراءات</span> {
</div> key: "recipient",
header: "المستلم",
{loading ? ( width: "1.6fr",
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> render: (o) => (
<Spinner size="sm" className="text-blue-600" /> <div>
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> <p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
</div> </div>
) : orders.length === 0 ? ( ),
<EmptyState },
icon="🗄️" {
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد طلبات في الأرشيف."} key: "amount",
/> header: "الكمية",
) : ( width: "1fr",
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> render: (o) => <span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "var(--color-text-primary)" }}>{fmtAmount(o.subTotal)}</span>,
{orders.map((o, i) => { },
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created; {
return ( key: "status",
<li key={o.id} style={{ header: "الحالة",
display: "grid", gridTemplateColumns: ROW_GRID, width: "1fr",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem", render: (o) => {
borderBottom: "1px solid var(--color-border)", const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", return (
fontSize: 13, <span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: s.color, width: "fit-content" }}>
}}> <span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB" }}> {s.label}
{o.shipmentNumber}
</span>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
</div>
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "var(--color-text-primary)" }}>
{fmtAmount(o.subTotal)}
</span>
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`,
background: s.bg, padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 600, color: s.color, width: "fit-content",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
{s.label}
</span>
<div style={{ display: "flex", justifyContent: "center" }}>
<IconBtn title={`عرض ${o.shipmentNumber}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(o)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
);
})}
</ul>
)}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span> </span>
<div style={{ display: "flex", gap: "0.5rem" }}> );
<Button },
type="button" },
variant="secondary" ];
size="sm"
onClick={() => onPageChange(Math.max(1, page - 1))} return (
disabled={page === 1} <ReusableTable
> columns={columns}
السابق data={orders}
</Button> loading={loading}
<Button search={search}
type="button" page={page}
variant="secondary" pages={pages}
size="sm" onPageChange={onPageChange}
onClick={() => onPageChange(Math.min(pages, page + 1))} emptyIcon="🗄️"
disabled={page === pages} emptyDescription={search ? `No results for "${search}"` : "No archived orders."}
> renderActions={(o) => <ActionButtons itemLabel={o.shipmentNumber} onView={() => onView(o)} />}
التالي />
</Button>
</div>
</div>
)}
</div>
); );
} }

View File

@@ -12,18 +12,7 @@ interface ArchivedOrdersModalProps {
} }
const searchIcon = ( const searchIcon = (
<svg <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
); );
export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) { export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {

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

@@ -1,8 +1,5 @@
"use client"; "use client";
// src/Components/Trip/TripDetailModal.tsx
// New — split out of the old single-file Trip module, mirroring
// UserDetailModal.tsx / ArchivedDriverDetailModal.tsx for consistency.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Alert, Button, Modal, Spinner } from "../UI"; import { Alert, Button, Modal, Spinner } from "../UI";

View File

@@ -1,17 +1,15 @@
"use client"; "use client";
// src/Components/Trip/TripTable.tsx // src/Components/Trip/TripTable.tsx
// New — split out of the old single-file Trip module, mirroring // MIGRATED to ReusableTable + ActionButtons — same props, same visual
// UserTable.tsx for consistency. // result as the original. StatusBadge kept custom since colors come
// dynamically from TRIP_STATUS_MAP (per-status color+dot).
import { Button, EmptyState, IconBtn, Spinner } from "../UI"; import { Button, ReusableTable, ActionButtons } from "../UI";
import { TRIP_STATUS_MAP } from "@/src/types/trip"; import { TRIP_STATUS_MAP } from "@/src/types/trip";
import type { TableColumn } from "@/src/types/models";
import type { Trip } from "@/src/types/trip"; import type { Trip } from "@/src/types/trip";
// ── status badge ─────────────────────────────────────────────────────────────
// Kept custom rather than swapped for <Badge/>: colors come dynamically from
// TRIP_STATUS_MAP (per-status color+dot), which Badge's fixed palette
// doesn't cover — mirrors DriverTable's StatusBadge.
function StatusBadge({ status }: { status: Trip["status"] }) { function StatusBadge({ status }: { status: Trip["status"] }) {
const s = TRIP_STATUS_MAP[status]; const s = TRIP_STATUS_MAP[status];
return ( return (
@@ -22,151 +20,84 @@ function StatusBadge({ status }: { status: Trip["status"] }) {
); );
} }
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
const ROW_GRID_COLUMNS = "2fr 1.3fr 1.3fr 1fr 1fr 1fr 100px";
function fmtDate(iso?: string | null): string { function fmtDate(iso?: string | null): string {
if (!iso) return "—"; if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" }); return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
} }
// ── Props ────────────────────────────────────────────────────────────────────
interface TripTableProps { interface TripTableProps {
trips: Trip[]; trips: Trip[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onEdit: (trip: Trip) => void; onEdit: (trip: Trip) => void;
onDelete: (trip: Trip) => void; onDelete: (trip: Trip) => void;
onView: (trip: Trip) => void; onView: (trip: Trip) => void;
onAddFirst: () => void; onAddFirst: () => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ─────────────────────────────────────────────────────────────── export function TripTable({
export function TripTable({ trips, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: TripTableProps) { trips, loading, search, page, pages,
onEdit, onDelete, onView, onAddFirst, onPageChange,
}: TripTableProps) {
const columns: TableColumn<Trip>[] = [
{
key: "title",
header: "الرحلة",
width: "2fr",
render: (t) => (
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{t.title}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{t.tripNumber}</p>
</div>
),
},
{ key: "driver", header: "السائق", width: "1.3fr", render: (t) => t.driver?.name ?? "—" },
{
key: "car",
header: "السيارة",
width: "1.3fr",
render: (t) => (t.car ? `${t.car.manufacturer} ${t.car.model}` : "—"),
},
{ key: "branch", header: "الفرع", width: "1fr", render: (t) => t.branch?.name ?? "—" },
{ key: "status", header: "الحالة", width: "1fr", render: (t) => <StatusBadge status={t.status} /> },
{
key: "startTime",
header: "معاد البداء",
width: "1fr",
align: "center",
render: (t) => fmtDate(t.startTime),
},
];
return ( return (
<div style={cardStyle}> <ReusableTable
{/* column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, ...thStyle }}> data={trips}
<span>الرحلة</span> loading={loading}
<span>السائق</span> search={search}
<span>السيارة</span> page={page}
<span>الفرع</span> pages={pages}
<span>الحالة</span> onPageChange={onPageChange}
<span style={{ textAlign: "center" }}>وقت البدء</span> emptyIcon="🚚"
<span style={{ textAlign: "center" }}>إجراءات</span> emptyDescription={!search ? "No trips to display." : undefined}
</div> emptyAction={
!search && (
{/* loading state */} <Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
{loading ? ( Add first trip
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> </Button>
<Spinner size="sm" className="text-blue-600" /> )
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> }
</div> renderActions={(t) => (
) : trips.length === 0 ? ( <ActionButtons
/* empty state */ itemLabel={t.title}
<EmptyState onView={() => onView(t)}
icon="🚚" onEdit={() => onEdit(t)}
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد رحلات لعرضها."} onDelete={() => onDelete(t)}
action={
!search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
أضف أول رحلة
</Button>
)
}
/> />
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{trips.map((t, i) => (
<li key={t.id} style={{
display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS,
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{t.title}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{t.tripNumber}</p>
</div>
<span style={{ color: "var(--color-text-secondary)" }}>{t.driver?.name ?? "—"}</span>
<span style={{ color: "var(--color-text-secondary)" }}>{t.car ? `${t.car.manufacturer} ${t.car.model}` : "—"}</span>
<span style={{ color: "var(--color-text-secondary)" }}>{t.branch?.name ?? "—"}</span>
<StatusBadge status={t.status} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{fmtDate(t.startTime)}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view */}
<IconBtn title={`عرض ${t.title}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(t)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
{/* edit */}
<IconBtn title={`تعديل ${t.title}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(t)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</IconBtn>
{/* delete */}
<IconBtn title={`حذف ${t.title}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(t)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)} )}
/>
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page === 1}
>
السابق
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => onPageChange(Math.min(pages, page + 1))}
disabled={page === pages}
>
التالي
</Button>
</div>
</div>
)}
</div>
); );
} }

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,22 +1,11 @@
"use client"; "use client";
// src/Components/Trip/TripFormModal.tsx
// CHANGE: renamed from Tripformmodal.tsx (proper PascalCase, matches
// UserFormModal.tsx / DriverFormModal.tsx). Split out of the old single-file
// Trip module into TripFormModal.tsx / TripTable.tsx / TripDetailModal.tsx.
// FIX 1: notes/endReason schema — empty string now transforms to undefined
// before .min() runs, so leaving them blank on create no longer fails
// validation despite .optional() (see trip.validator.ts).
// FIX 2: edit-mode defaultValues now fall back to the nested driver/car/branch
// ids, and setValue() re-applies the saved id once each dropdown's options
// actually load — previously the <select> had no matching <option> yet when
// defaultValues were first applied, so the old selection silently reset to "".
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, Input, Modal, Select, Textarea } from "../UI"; import { Alert, Button, Input, Modal, Select, Textarea } from "../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { useEditFormSync } from "@/src/hooks/useEditFormSync";
import { import {
createTripSchema, createTripSchema,
updateTripSchema, updateTripSchema,
@@ -138,30 +127,25 @@ export function TripFormModal({
}, },
}); });
useEffect(() => { // ── Fetch dropdown options once on mount ────────────────────────────────────
const token = getStoredToken(); // CHANGE: split fetching from syncing — fetching stays a plain useEffect,
// syncing the saved id back into the form is now handled by useEditFormSync
// below, once per field, instead of being inlined into each .then().
useEffect(() => {
const token = getStoredToken();
driverService.getActiveOptions(token).then((list) => { driverService.getActiveOptions(token).then(setDrivers).catch(() => {});
setDrivers(list); carService.getActiveOptions(token).then(setCars).catch(() => {});
// defaultValues were applied before this list existed, so the <select> branchService.getOptions(token).then(setBranches).catch(() => {});
// had no matching <option> yet and silently fell back to "" — reapply }, []);
// the saved id now that the option actually exists in the DOM.
const savedDriverId = editTrip?.driverId ?? editTrip?.driver?.id;
if (savedDriverId) setValue("driverId", savedDriverId);
}).catch(() => {});
carService.getActiveOptions(token).then((list) => { // CHANGE: replaced the three manual `if (savedXId) setValue(...)` calls
setCars(list); // (previously inlined inside each .then() above) with useEditFormSync —
const savedCarId = editTrip?.carId ?? editTrip?.car?.id; // each hook call re-applies the saved id once its matching option list
if (savedCarId) setValue("carId", savedCarId); // has loaded, regardless of which fetch resolves first.
}).catch(() => {}); useEditFormSync(setValue, "driverId", editTrip?.driverId ?? editTrip?.driver?.id, drivers);
useEditFormSync(setValue, "carId", editTrip?.carId ?? editTrip?.car?.id, cars);
branchService.getOptions(token).then((list) => { useEditFormSync(setValue, "branchId", editTrip?.branchId ?? editTrip?.branch?.id, branches);
setBranches(list);
const savedBranchId = editTrip?.branchId ?? editTrip?.branch?.id;
if (savedBranchId) setValue("branchId", savedBranchId);
}).catch(() => {});
}, [editTrip, setValue]);
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");

View File

@@ -0,0 +1,167 @@
"use client";
import { Spinner, Alert, EmptyState } from "@/src/Components/UI";
import { useArchivedTrips } from "@/src/hooks/archive/useArchivedTrips";
import { TRIP_STATUS_MAP } from "@/src/types/trip";
import type { Trip } from "@/src/types/trip";
interface ArchivedTripListProps {
/** Called when the user clicks a row to view trip details */
onView?: (trip: Trip) => void;
}
// ── Status badge — reuses the existing trip status color map ────────────────
function TripStatusBadge({ status }: { status: Trip["status"] }) {
const s = TRIP_STATUS_MAP[status];
return (
<span
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-bold"
style={{ color: s.color, background: s.bg, border: `1px solid ${s.border}` }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />
{s.label}
</span>
);
}
// ── Date formatting helper ───────────────────────────────────────────────────
function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" });
}
/**
* ArchivedTripList
* Displays a paginated, searchable list of archived trips.
* Fetches data via useArchivedTrips (GET /trip/archived), handles loading,
* empty, and error states, and renders each trip with its key details.
*
* @example
* <ArchivedTripList onView={(trip) => setViewTripId(trip.id)} />
*/
export function ArchivedTripList({ onView }: ArchivedTripListProps) {
const {
trips, loading, total, pages, page, search, error,
setPage, handleSearch, clearError,
} = useArchivedTrips();
return (
<div dir="rtl" className="flex flex-col gap-4">
{/* ── Header: count + search ── */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-base font-bold text-[var(--color-text-primary)]">
الرحلات المؤرشفة
<span className="mr-2 text-[13px] font-medium text-[var(--color-text-muted)]">
({total})
</span>
</h2>
<div className="relative w-full sm:w-72">
<svg
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--color-text-hint)]"
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
>
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث برقم الرحلة أو العنوان..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
className="h-10 w-full rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] pr-9 pl-3 text-[13px] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-brand-600)] focus:ring-2 focus:ring-[var(--color-brand-600)]/15"
/>
</div>
</div>
{/* ── API error feedback ── */}
{error && <Alert type="error" message={error} onClose={clearError} />}
{/* ── Card ── */}
<div className="overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]">
{/* column headers */}
<div className="grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-muted)] px-6 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
<span>الرحلة</span>
<span>الحالة</span>
<span className="text-center">البدء</span>
<span className="text-center">الانتهاء</span>
<span className="text-center">النقد المحصّل</span>
</div>
{/* ── Loading state ── */}
{loading ? (
<div className="flex items-center justify-center gap-3 py-16 text-[var(--color-text-muted)]">
<Spinner size="sm" />
<span className="text-[13px]">جارٍ التحميل</span>
</div>
) : trips.length === 0 ? (
/* ── Empty state ── */
<EmptyState
icon="🗄️"
title="لا توجد رحلات مؤرشفة"
description={search ? `لا توجد نتائج لـ "${search}"` : "لم يتم أرشفة أي رحلات بعد."}
/>
) : (
/* ── Data rows ── */
<ul className="m-0 list-none p-0">
{trips.map((trip, i) => (
<li
key={trip.id}
onClick={() => onView?.(trip)}
className={`grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] items-center gap-2 border-b border-[var(--color-border)] px-6 py-3.5 text-[13px] transition-colors hover:bg-[var(--color-surface-muted)] ${
onView ? "cursor-pointer" : ""
} ${i % 2 !== 0 ? "bg-[var(--color-surface-muted)]/40" : ""}`}
>
<div className="min-w-0">
<p className="m-0 truncate font-semibold text-[var(--color-text-primary)]">{trip.title}</p>
<p className="mt-0.5 font-mono text-[11px] text-[var(--color-brand-600)]">{trip.tripNumber}</p>
</div>
<div>
<TripStatusBadge status={trip.status} />
</div>
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
{fmtDate(trip.startTime)}
</span>
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
{fmtDate(trip.endTime)}
</span>
<span className="text-center font-mono text-[12px] font-semibold text-[var(--color-text-primary)]">
{trip.totalCashCollected != null
? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س`
: "—"}
</span>
</li>
))}
</ul>
)}
{/* ── Pagination ── */}
{pages > 1 && (
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-6 py-3.5">
<span className="text-[12px] text-[var(--color-text-muted)]">
صفحة <strong className="text-[var(--color-text-primary)]">{page}</strong> من{" "}
<strong className="text-[var(--color-text-primary)]">{pages}</strong>
</span>
<div className="flex gap-2">
<button
type="button"
disabled={page === 1}
onClick={() => setPage(Math.max(1, page - 1))}
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3.5 py-1.5 text-[12px] text-[var(--color-text-secondary)] disabled:cursor-not-allowed disabled:opacity-40"
>
السابق
</button>
<button
type="button"
disabled={page === pages}
onClick={() => setPage(Math.min(pages, page + 1))}
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3.5 py-1.5 text-[12px] text-[var(--color-text-secondary)] disabled:cursor-not-allowed disabled:opacity-40"
>
التالي
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,9 +1,6 @@
"use client"; "use client";
// src/Components/Trip/archive/ArchivedTripModal.tsx
// Split out of the old ArchivedTripList.tsx — this is the top-level modal
// wrapper (search bar + ArchivedTripTable + opens ArchivedTripDetailModal),
// mirroring ArchivedDrivers.tsx / ArchivedUsersModal.tsx.
import { useState } from "react"; import { useState } from "react";
import { Alert, Input, Modal } from "@/src/Components/UI"; import { Alert, Input, Modal } from "@/src/Components/UI";
@@ -16,18 +13,7 @@ interface ArchivedTripModalProps {
} }
const searchIcon = ( const searchIcon = (
<svg <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
); );
/** /**

View File

@@ -1,135 +1,79 @@
"use client"; "use client";
// src/Components/Trip/archive/ArchivedTripTable.tsx import { ReusableTable } from "@/src/Components/UI";
// Split out of the old ArchivedTripList.tsx — this is just the table portion
// (headers, rows, pagination), mirroring ArchivedDriverTable.tsx. Styling is
// preserved exactly (Tailwind classes) as it was in the original file.
import { Spinner, EmptyState, Button } from "@/src/Components/UI";
import { TRIP_STATUS_MAP } from "@/src/types/trip"; import { TRIP_STATUS_MAP } from "@/src/types/trip";
import type { TableColumn } from "@/src/types/models";
import type { Trip } from "@/src/types/trip"; import type { Trip } from "@/src/types/trip";
// ── Status badge — reuses the existing trip status color map ────────────────
function TripStatusBadge({ status }: { status: Trip["status"] }) {
const s = TRIP_STATUS_MAP[status];
return (
<span
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-bold"
style={{ color: s.color, background: s.bg, border: `1px solid ${s.border}` }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />
{s.label}
</span>
);
}
// ── Date formatting helper ───────────────────────────────────────────────────
function fmtDate(iso?: string | null): string { function fmtDate(iso?: string | null): string {
if (!iso) return "—"; if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" }); return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
} }
interface ArchivedTripTableProps { interface ArchivedTripTableProps {
trips: Trip[]; trips: Trip[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onView: (trip: Trip) => void; onView: (trip: Trip) => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
/**
* ArchivedTripTable
* Renders the paginated, searchable table of archived trips, including
* loading, empty, and data-row states. Mirrors ArchivedDriverTable.tsx.
*/
export function ArchivedTripTable({ trips, loading, search, page, pages, onView, onPageChange }: ArchivedTripTableProps) { export function ArchivedTripTable({ trips, loading, search, page, pages, onView, onPageChange }: ArchivedTripTableProps) {
return ( const columns: TableColumn<Trip>[] = [
<div className="overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]"> {
{/* column headers */} key: "title",
<div className="grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-muted)] px-6 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-[var(--color-text-muted)]"> header: "الرحلة",
<span>الرحلة</span> width: "2fr",
<span>الحالة</span> render: (t) => (
<span className="text-center">البدء</span> <div>
<span className="text-center">الانتهاء</span> <p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{t.title}</p>
<span className="text-center">النقد المحصّل</span> <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-brand-600)" }}>{t.tripNumber}</p>
</div>
{/* ── Loading state ── */}
{loading ? (
<div className="flex items-center justify-center gap-3 py-16 text-[var(--color-text-muted)]">
<Spinner size="sm" />
<span className="text-[13px]">جارٍ التحميل</span>
</div> </div>
) : trips.length === 0 ? ( ),
/* ── Empty state ── */ },
<EmptyState {
icon="🗄️" key: "status",
title="لا توجد رحلات مؤرشفة" header: "الحالة",
description={search ? `لا توجد نتائج لـ "${search}"` : "لم يتم أرشفة أي رحلات بعد."} width: "1.5fr",
/> render: (t) => {
) : ( const s = TRIP_STATUS_MAP[t.status];
/* ── Data rows ── */ return (
<ul className="m-0 list-none p-0"> <span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: s.color }}>
{trips.map((trip, i) => ( <span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
<li {s.label}
key={trip.id}
onClick={() => onView(trip)}
className={`grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] items-center gap-2 border-b border-[var(--color-border)] px-6 py-3.5 text-[13px] transition-colors hover:bg-[var(--color-surface-muted)] cursor-pointer ${
i % 2 !== 0 ? "bg-[var(--color-surface-muted)]/40" : ""
}`}
>
<div className="min-w-0">
<p className="m-0 truncate font-semibold text-[var(--color-text-primary)]">{trip.title}</p>
<p className="mt-0.5 font-mono text-[11px] text-[var(--color-brand-600)]">{trip.tripNumber}</p>
</div>
<div>
<TripStatusBadge status={trip.status} />
</div>
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
{fmtDate(trip.startTime)}
</span>
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
{fmtDate(trip.endTime)}
</span>
<span className="text-center font-mono text-[12px] font-semibold text-[var(--color-text-primary)]">
{trip.totalCashCollected != null
? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س`
: "—"}
</span>
</li>
))}
</ul>
)}
{/* ── Pagination ── */}
{pages > 1 && (
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-6 py-3.5">
<span className="text-[12px] text-[var(--color-text-muted)]">
صفحة <strong className="text-[var(--color-text-primary)]">{page}</strong> من{" "}
<strong className="text-[var(--color-text-primary)]">{pages}</strong>
</span> </span>
<div className="flex gap-2"> );
<Button },
variant="secondary" },
size="sm" { key: "start", header: "تاريخ البداية", width: "1fr", align: "center", render: (t) => fmtDate(t.startTime) },
disabled={page === 1} { key: "end", header: "تاريخ الانتهاء", width: "1fr", align: "center", render: (t) => fmtDate(t.endTime) },
onClick={() => onPageChange(Math.max(1, page - 1))} {
> key: "cash",
السابق header: "المبلغ المحصل",
</Button> width: "1fr",
<Button align: "center",
variant="secondary" render: (t) => (
size="sm" <span style={{ fontWeight: 600, fontFamily: "var(--font-mono)" }}>
disabled={page === pages} {t.totalCashCollected != null ? `${Number(t.totalCashCollected).toLocaleString("en-US")} SAR` : "—"}
onClick={() => onPageChange(Math.min(pages, page + 1))} </span>
> ),
التالي },
</Button> ];
</div>
</div> return (
)} <ReusableTable
</div> columns={columns}
data={trips}
loading={loading}
search={search}
page={page}
pages={pages}
onPageChange={onPageChange}
onRowClick={onView}
emptyIcon="🗄️"
emptyDescription={search ? `No results for "${search}"` : "No archived trips."}
/>
); );
} }

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

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

@@ -25,4 +25,7 @@ export { Badge } from "./Badge";
export { ArchiveButton } from "./ArchiveButton" export { ArchiveButton } from "./ArchiveButton"
export { FileInput } from "./FailInput" export { FileInput } from "./FailInput"
export {IconBtn} from "./IconBtn" export {IconBtn} from "./IconBtn"
export { ReusableTable } from "./ReusableTable";
export { ActionButtons } from "./ActionButtons";
export { ModelCard } from "./ModelCard";

View File

@@ -0,0 +1,58 @@
"use client";
import { useEffect } from "react";
import { Spinner } from "../UI";
import type { User } from "@/src/types/user";
interface DeleteConfirmModalProps {
user: User;
deleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function DeleteConfirmModal({ user, deleting, onCancel, onConfirm }: DeleteConfirmModalProps) {
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onCancel]);
return (
<div
role="alertdialog" aria-modal="true" aria-labelledby="del-title"
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
style={{ position: "fixed", inset: 0, zIndex: 60, background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}
>
<div
onClick={e => e.stopPropagation()}
style={{ width: "100%", maxWidth: 400, background: "var(--color-surface)", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", boxShadow: "0 20px 48px rgba(0,0,0,.18)", padding: "2rem", display: "flex", flexDirection: "column", gap: "1rem", textAlign: "center" }}
>
{/* icon delete */}
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</div>
<div>
<h2 id="del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>حذف المستخدم</h2>
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
هل أنت متأكد من حذف <strong style={{ color: "var(--color-text-primary)" }}>{user.name}</strong>؟ لا يمكن التراجع عن هذا الإجراء.
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={onCancel} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء
</button>
<button type="button" onClick={onConfirm} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
{deleting && <Spinner size="sm" className="text-white" />}
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import type { Resolver } from "react-hook-form"; import type { Resolver } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, Input, Select } from "../UI"; import { Alert, Button, Input, Select } from "../UI";
import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator"; import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator";
import { useEditFormSync } from "@/src/hooks/useEditFormSync"; // CHANGE: added hook — fixes roleId/branchId not pre-selecting on edit
import type { Branch } from "@/src/types/branch"; import type { Branch } from "@/src/types/branch";
import type { Role } from "@/src/types/role"; import type { Role } from "@/src/types/role";
import type { User, UserFormData } from "@/src/types/user"; import type { User, UserFormData } from "@/src/types/user";
@@ -30,11 +30,6 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
const resolver: Resolver<UserFormData> = async (values, context, options) => { const resolver: Resolver<UserFormData> = async (values, context, options) => {
const schema = isNew ? createUserSchema : updateUserSchema; const schema = isNew ? createUserSchema : updateUserSchema;
const data = !isNew && !values.password ? { ...values, password: undefined } : values; const data = !isNew && !values.password ? { ...values, password: undefined } : values;
// Cast both the schema argument and yupResolver's own return value — same
// fix already applied in TripFormModal/DriverFormModal/CarFormModal:
// passing an explicit <UserFormData> generic here forces a structural
// comparison between yup's inferred optional-field shape and
// UserFormData that fails the same way it did for those forms.
return (yupResolver(schema as any) as any)(data as UserFormData, context, options); return (yupResolver(schema as any) as any)(data as UserFormData, context, options);
}; };
@@ -56,25 +51,26 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
}, },
}); });
// roles/branches are passed in as props rather than fetched inside this // CHANGE: replaced the two manual useEffect blocks with useEditFormSync —
// modal (unlike Trip/Driver/Car), but the same timing issue applies if the // same behavior (re-apply saved id once its option list contains it),
// parent still has them loading when the modal first mounts: defaultValues // reused across Driver/Car/Trip forms that have the same bug pattern.
// are applied before the matching <option> exists, so the <select> useEditFormSync(setValue, "roleId", editUser?.role?.id, roles);
// silently falls back to "". Reapply the saved id once each list actually useEditFormSync(setValue, "branchId", editUser?.branch?.id, branches);
// contains it.
// CHANGE: surface an inline error if the saved role/branch no longer
// exists in the fetched list, instead of silently falling back to the
// placeholder with no explanation.
useEffect(() => { useEffect(() => {
const savedRoleId = editUser?.role?.id; if (editUser?.role?.id && roles.length && !roles.some(r => r.id === editUser.role!.id)) {
if (savedRoleId && roles.some(r => r.id === savedRoleId)) { setError("roleId", { message: "الدور المحفوظ لم يعد متاحًا، الرجاء اختيار دور آخر" });
setValue("roleId", savedRoleId);
} }
}, [roles, editUser, setValue]); }, [roles, editUser, setError]);
useEffect(() => { useEffect(() => {
const savedBranchId = editUser?.branch?.id; if (editUser?.branch?.id && branches.length && !branches.some(b => b.id === editUser.branch!.id)) {
if (savedBranchId && branches.some(b => b.id === savedBranchId)) { setError("branchId", { message: "الفرع المحفوظ لم يعد متاحًا، الرجاء اختيار فرع آخر" });
setValue("branchId", savedBranchId);
} }
}, [branches, editUser, setValue]); }, [branches, editUser, setError]);
const submitHandler = async (data: UserFormData) => { const submitHandler = async (data: UserFormData) => {
const payload: Partial<UserFormData> = { ...data }; const payload: Partial<UserFormData> = { ...data };
@@ -136,7 +132,17 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
</div> </div>
{/* body */} {/* body */}
<form onSubmit={handleSubmit(submitHandler)} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}> {/* CHANGE: added suppressHydrationWarning — form elements are the
most common target for browser-extension-injected attributes
(e.g. bis_skin_checked from Bitdefender), which caused the
hydration mismatch error on Save. See ExtensionAttributeCleanup
for the global fix; this is a scoped safety net. */}
<form
onSubmit={handleSubmit(submitHandler)}
noValidate
suppressHydrationWarning
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}
>
{errors.name?.type === "manual" && ( {errors.name?.type === "manual" && (
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} /> <Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
)} )}

View File

@@ -1,158 +1,115 @@
"use client"; "use client";
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../UI"; import { Badge, ReusableTable, ActionButtons, Button } from "@/src/Components/UI";
import type { TableColumn } from "@/src/types/models";
import type { User } from "@/src/types/user"; import type { User } from "@/src/types/user";
// ── status badge ─────────────────────────────────────────────────────────────
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
function StatusBadge({ active }: { active: boolean }) { function StatusBadge({ active }: { active: boolean }) {
return ( return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}> <span
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.2rem 0.625rem",
fontSize: 11,
fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} /> <span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"} {active ? "Active" : "Disabled"}
</span> </span>
); );
} }
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Props ────────────────────────────────────────────────────────────────────
interface UserTableProps { interface UserTableProps {
users: User[]; users: User[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onEdit: (user: User) => void; onEdit: (user: User) => void;
onDelete: (user: User) => void; onDelete: (user: User) => void;
onView: (user: User) => void; onView: (user: User) => void;
onAddFirst: () => void; onAddFirst: () => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ─────────────────────────────────────────────────────────────── export function UserTable({
export function UserTable({ users, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: UserTableProps) { users, loading, search, page, pages,
onEdit, onDelete, onView, onAddFirst, onPageChange,
}: UserTableProps) {
const columns: TableColumn<User>[] = [
{
key: "name",
header: "الاسم",
width: "2fr",
render: (u) => (
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
{u.phone}
</p>
</div>
),
},
{
key: "userName",
header: "اسم المستخدم",
width: "1.5fr",
render: (u) => (
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
{u.userName ?? "—"}
</span>
),
},
{ key: "branch", header: "الفرع", width: "1.5fr", render: (u) => u.branch?.name ?? "—" },
{
key: "role",
header: "الدور",
width: "1fr",
render: (u) => <Badge label={u.role?.name ?? "—"} color={u.role?.name === "System Admin" ? "indigo" : "slate"} />,
},
{ key: "status", header: "الحالة", width: "1fr", render: (u) => <StatusBadge active={u.isActive} /> },
{
key: "createdAt",
header: "تم الانشاء",
width: "1fr",
align: "center",
render: (u) => new Date(u.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
},
];
return ( return (
<div style={cardStyle}> <ReusableTable
{/* column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> data={users}
<span>الاسم</span> loading={loading}
<span>اسم المستخدم</span> search={search}
<span>الفرع</span> page={page}
<span>الدور</span> pages={pages}
<span>الحالة</span> onPageChange={onPageChange}
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> emptyIcon="👥"
<span style={{ textAlign: "center" }}>إجراءات</span> emptyDescription={!search ? "No users to display." : undefined}
</div> emptyAction={
!search && (
{/* loading state */} <Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
{loading ? ( Add first user
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> </Button>
<Spinner size="sm" className="text-blue-600" /> )
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> }
</div> renderActions={(u) => (
) : users.length === 0 ? ( <ActionButtons
/* empty state */ itemLabel={u.name}
<EmptyState onView={() => onView(u)}
icon="👥" onEdit={() => onEdit(u)}
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."} onDelete={() => onDelete(u)}
action={
!search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
أضف أول مستخدم
</Button>
)
}
/> />
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{users.map((u, i) => (
<li key={u.id} style={{
display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
</div>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
<span style={{ color: "var(--color-text-secondary)" }}>{u.branch?.name ?? "—"}</span>
<Badge label={u.role?.name ?? "—"} color={u.role?.name === "مدير النظام" ? "indigo" : "slate"} />
<StatusBadge active={u.isActive} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view */}
<IconBtn title={`عرض ${u.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(u)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
{/* edit */}
<IconBtn title={`تعديل ${u.name}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(u)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</IconBtn>
{/* delete */}
<IconBtn title={`حذف ${u.name}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(u)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)} )}
/>
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page === 1}
>
السابق
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => onPageChange(Math.min(pages, page + 1))}
disabled={page === pages}
>
التالي
</Button>
</div>
</div>
)}
</div>
); );
} }

View File

@@ -11,18 +11,7 @@ interface ArchivedUsersModalProps {
} }
const searchIcon = ( const searchIcon = (
<svg <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
); );
export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) { export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {

View File

@@ -1,134 +1,79 @@
"use client"; "use client";
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
import { ReusableTable, ActionButtons } from "../../UI";
import type { TableColumn } from "@/src/types/models";
import type { ArchivedUser } from "@/src/types/user"; import type { ArchivedUser } from "@/src/types/user";
// ── status badge ─────────────────────────────────────────────────────────────
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
function StatusBadge({ active }: { active: boolean }) { function StatusBadge({ active }: { active: boolean }) {
return ( return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}> <span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} /> <span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"} {active ? "Active" : "Disabled"}
</span> </span>
); );
} }
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Props ────────────────────────────────────────────────────────────────────
interface ArchivedUserTableProps { interface ArchivedUserTableProps {
users: ArchivedUser[]; users: ArchivedUser[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onView: (user: ArchivedUser) => void; onView: (user: ArchivedUser) => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ───────────────────────────────────────────────────────────────
export function ArchivedUserTable({ users, loading, search, page, pages, onView, onPageChange }: ArchivedUserTableProps) { export function ArchivedUserTable({ users, loading, search, page, pages, onView, onPageChange }: ArchivedUserTableProps) {
const columns: TableColumn<ArchivedUser>[] = [
{
key: "name",
header: "الاسم",
width: "2fr",
render: (u) => (
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
</div>
),
},
{
key: "userName",
header: "الاسم المستخدم",
width: "1.5fr",
render: (u) => (
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
),
},
{ key: "status", header: "الحالة", width: "1fr", render: (u) => <StatusBadge active={u.isActive} /> },
{
key: "createdAt",
header: "تاريخ الانشاء",
width: "1fr",
align: "center",
render: (u) => new Date(u.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
},
{
key: "updatedAt",
header: "تاريخ التعديل",
width: "1fr",
align: "center",
render: (u) => new Date(u.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
}
];
return ( return (
<div style={cardStyle}> <ReusableTable
{/* column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> data={users}
<span>الاسم</span> loading={loading}
<span>اسم المستخدم</span> search={search}
<span>الحالة</span> page={page}
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> pages={pages}
<span style={{ textAlign: "center" }}>آخر تحديث</span> onPageChange={onPageChange}
<span style={{ textAlign: "center" }}>إجراءات</span> emptyIcon="🗄️"
</div> emptyDescription={search ? `No results for "${search}"` : "No archived users."}
renderActions={(u) => <ActionButtons itemLabel={u.name} onView={() => onView(u)} />}
{/* loading state */} />
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : users.length === 0 ? (
/* empty state */
<EmptyState
icon="🗄️"
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون في الأرشيف."}
/>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{users.map((u, i) => (
<li key={u.id} style={{
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
</div>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
<StatusBadge active={u.isActive} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(u.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view only — delete/restore not implemented yet */}
<IconBtn title={`عرض ${u.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(u)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)}
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page === 1}
>
السابق
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => onPageChange(Math.min(pages, page + 1))}
disabled={page === pages}
>
التالي
</Button>
</div>
</div>
)}
</div>
); );
} }

View File

@@ -0,0 +1,136 @@
"use client";
import { AuditLog } from "@/src/types/audit";
import { Button, Modal } from "../UI";
import { ActionBadge } from "./AuditTable";
interface AuditDetailModalProps {
log: AuditLog;
onClose: () => void;
}
// ── small helper components ───────────────────────────────────────────────
// (Same rationale as UserDetailModal's: purpose-built layouts, not generic
// form/action controls, so they stay custom rather than pulled from the
// shared UI kit.)
function DetailRow({ label, value, mono }: { label: string; value?: string | null; mono?: boolean }) {
return (
<div style={{
display: "flex", flexDirection: "column", gap: 4,
padding: "0.75rem 0",
borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
{label}
</span>
<span style={{
fontSize: 13, fontWeight: 500,
fontFamily: mono ? "var(--font-mono)" : undefined,
color: value ? "var(--color-text-primary)" : "var(--color-text-hint)",
wordBreak: "break-word",
}}>
{value || "—"}
</span>
</div>
);
}
// Stands in for UserDetailModal's <Avatar/> — a log entry has no photo, so
// this is a colored icon circle instead of initials.
function ActionIcon() {
return (
<div style={{
width: 64, height: 64, borderRadius: "50%",
background: "linear-gradient(135deg, #2563EB 0%, #7C3AED 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0,
boxShadow: "0 4px 12px rgba(37,99,235,.3)",
}}>
<i className="ti ti-clipboard-check" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
</div>
);
}
function MetadataBlock({ metadata }: { metadata: Record<string, unknown> }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4, padding: "0.75rem 0" }}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
بيانات إضافية
</span>
<pre style={{
margin: 0,
padding: "0.75rem",
borderRadius: "var(--radius-md)",
background: "var(--color-surface-muted)",
border: "1px solid var(--color-border)",
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--color-text-secondary)",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
direction: "ltr",
textAlign: "left",
}}>
{JSON.stringify(metadata, null, 2)}
</pre>
</div>
);
}
// ── main component ──────────────────────────────────────────────────────
export function AuditDetailModal({ log, onClose }: AuditDetailModalProps) {
const fmt = (iso?: string | null) =>
iso ? new Date(iso).toLocaleString("ar-SA", { dateStyle: "long", timeStyle: "medium" }) : null;
return (
<Modal
open
title="تفاصيل السجل"
subtitle="سجل التدقيق"
onClose={onClose}
size="md"
footer={
<Button type="button" variant="secondary" onClick={onClose}>
إغلاق
</Button>
}
>
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
{/* icon + action + timestamp row */}
<div style={{
display: "flex", alignItems: "center", gap: "1rem",
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<ActionIcon />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
{log.module ?? "سجل تدقيق"}
</p>
<p style={{ marginTop: 3, fontSize: 12, color: "var(--color-text-muted)" }}>
{fmt(log.createdAt)}
</p>
<div style={{ marginTop: 8 }}>
<ActionBadge action={log.action} />
</div>
</div>
</div>
{/* detail rows */}
<DetailRow label="المعرّف" value={log.entityId} mono />
<DetailRow label="المستخدم" value={log.userName ?? log.userId} />
<DetailRow label="عنوان IP" value={log.ipAddress} mono />
<DetailRow label="متصفح المستخدم" value={log.userAgent} mono />
{log.metadata && Object.keys(log.metadata).length > 0 && (
<MetadataBlock metadata={log.metadata} />
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import { ReusableTable } from "../UI";
import type { TableColumn } from "@/src/types/models";
import type { AuditLog } from "@/src/types/audit";
// ── Action badge — moved verbatim from app/dashboard/audit/page.tsx ─────────
const ACTION_COLORS: Record<
string,
{ bg: string; color: string; border: string }
> = {
CREATE: { bg: "#DCFCE7", color: "#166534", border: "#BBF7D0" },
UPDATE: { bg: "#EFF6FF", color: "#1D4ED8", border: "#BFDBFE" },
DELETE: { bg: "#FEF2F2", color: "#DC2626", border: "#FECACA" },
LOGIN: { bg: "#F5F3FF", color: "#5B21B6", border: "#DDD6FE" },
LOGOUT: { bg: "#F1F5F9", color: "#475569", border: "#E2E8F0" },
};
// Exported (not just used locally) so AuditDetailModal can render the same
// badge in its header instead of re-implementing the color mapping.
export function ActionBadge({ action }: { action: string }) {
const upper = action?.toUpperCase() ?? "";
const key =
Object.keys(ACTION_COLORS).find((k) => upper.includes(k)) ?? "CREATE";
const cfg = ACTION_COLORS[key];
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
borderRadius: "var(--radius-full)",
border: `1px solid ${cfg.border}`,
background: cfg.bg,
padding: "0.2rem 0.625rem",
fontSize: 11,
fontWeight: 700,
color: cfg.color,
whiteSpace: "nowrap",
}}
>
{action}
</span>
);
}
function fmtDateTime(iso: string): string {
return new Date(iso).toLocaleString("ar-SA", {
dateStyle: "short",
timeStyle: "short",
});
}
interface AuditTableProps {
logs: AuditLog[];
loading: boolean;
search: string;
page: number;
pages: number;
onPageChange: (p: number) => void;
onRowClick: (log: AuditLog) => void;
}
export function AuditTable({
logs, loading, search, page, pages,
onPageChange, onRowClick,
}: AuditTableProps) {
const columns: TableColumn<AuditLog>[] = [
{
key: "action",
header: "الإجراء",
width: "1.5fr",
render: (log) => (
<div>
<ActionBadge action={log.action} />
{log.entityId && (
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--color-text-muted)" }}>
{log.entityId.slice(0, 12)}
</p>
)}
</div>
),
},
{ key: "module", header: "الوحدة", width: "1fr", render: (log) => log.module ?? "—" },
{ key: "user", header: "المستخدم", width: "1fr", render: (log) => log.userName ?? log.userId ?? "—" },
{
key: "ipAddress",
header: "عنوان IP",
width: "1.5fr",
render: (log) => (
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-muted)" }}>
{log.ipAddress ?? "—"}
</span>
),
},
{
key: "createdAt",
header: "الوقت",
width: "1fr",
render: (log) => (
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
{fmtDateTime(log.createdAt)}
</span>
),
},
];
return (
// Now showing ALL matching logs in one request (see the page's
// loadLogs), so instead of paging through them, the list scrolls
// internally within a fixed max-height. `pages` will naturally stay
// at 1 for a normal-sized result set, so ReusableTable's own pager
// stays hidden; this wrapper is what actually gives the scroll.
<div style={{ maxHeight: "70vh", overflowY: "auto", borderRadius: "var(--radius-xl)" }}>
<ReusableTable
columns={columns}
data={logs}
loading={loading}
search={search}
page={page}
pages={pages}
onPageChange={onPageChange}
onRowClick={onRowClick}
emptyDescription={search ? `لا نتائج لـ "${search}"` : "لا توجد سجلات مطابقة"}
/>
</div>
);
}

View File

@@ -6,7 +6,10 @@ import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, Input } from "@/src/Components/UI"; import { Alert, Button, Input } from "@/src/Components/UI";
import { useAuth } from "@/src/hooks/useAuth"; import { useAuth } from "@/src/hooks/useAuth";
import Logo from "@/src/utils/logo"; import Logo from "@/src/utils/logo";
import { loginSchema, type LoginFormData } from "@/src/validations/auth.validator"; import {
loginSchema,
type LoginFormData,
} from "@/src/validations/auth.validator";
export function LoginForm() { export function LoginForm() {
const { login, loading, error, clearError } = useAuth(); const { login, loading, error, clearError } = useAuth();
@@ -25,47 +28,212 @@ export function LoginForm() {
}; };
return ( return (
<main style={{ minHeight: "100vh", background: "var(--color-surface-muted)", color: "var(--color-text-primary)" }}> <main
<section style={{ minHeight: "100vh", width: "100%", display: "grid", gridTemplateColumns: "1fr 1fr" }}> style={{
<aside style={{ position: "relative", overflow: "hidden", background: "linear-gradient(135deg, #0f172a 0%, #2563EB 50%, #3b82f6 100%)", color: "#FFF", padding: "2.5rem 3rem", display: "flex", flexDirection: "column", justifyContent: "space-between" }}> minHeight: "100vh",
<div aria-hidden="true" style={{ position: "absolute", inset: 0, opacity: 0.28, backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)", backgroundSize: "40px 40px" }} /> background: "var(--color-surface-muted)",
<div style={{ position: "relative", zIndex: 1, display: "flex", flexDirection: "column", justifyContent: "space-between", height: "100%" }}> color: "var(--color-text-primary)",
}}
>
<section
style={{
minHeight: "100vh",
width: "100%",
display: "grid",
gridTemplateColumns: "1fr 1fr",
}}
>
<aside
style={{
position: "relative",
overflow: "hidden",
background:
"linear-gradient(135deg, #0f172a 0%, #2563EB 50%, #3b82f6 100%)",
color: "#FFF",
padding: "2.5rem 3rem",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
<div
aria-hidden="true"
style={{
position: "absolute",
inset: 0,
opacity: 0.28,
backgroundImage:
"radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
<div
style={{
position: "relative",
zIndex: 1,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
height: "100%",
}}
>
<div> <div>
<Logo white={true} /> <Logo white={true} href="/" />
<h1 style={{ marginTop: "2.5rem", maxWidth: "28rem", fontSize: "2.25rem", fontWeight: 700, lineHeight: 1.2, letterSpacing: "-0.02em" }}> <h1
style={{
marginTop: "2.5rem",
maxWidth: "28rem",
fontSize: "2.25rem",
fontWeight: 700,
lineHeight: 1.2,
letterSpacing: "-0.02em",
}}
>
اللوجستيات ببساطة، والتسليم بثقة. اللوجستيات ببساطة، والتسليم بثقة.
</h1> </h1>
<p style={{ marginTop: "1rem", maxWidth: "28rem", fontSize: "0.95rem", lineHeight: 1.8, color: "rgba(255,255,255,0.75)" }}> <p
راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب من خلال تسجيل دخول آمن واحد. style={{
marginTop: "1rem",
maxWidth: "28rem",
fontSize: "0.95rem",
lineHeight: 1.8,
color: "rgba(255,255,255,0.75)",
}}
>
راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب
من خلال تسجيل دخول آمن واحد.
</p> </p>
<div style={{ marginTop: "2rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}> <div
style={{
marginTop: "2rem",
display: "flex",
flexDirection: "column",
gap: "0.75rem",
}}
>
{[ {[
["التنسيق اللحظي", "تتبع كل طلب من الاستلام إلى التسليم في مكان واحد."], [
["وصول آمن", "الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة."], "التنسيق اللحظي",
["عمليات سريعة", "استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات."], "تتبع كل طلب من الاستلام إلى التسليم في مكان واحد.",
],
[
"وصول آمن",
"الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة.",
],
[
"عمليات سريعة",
"استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات.",
],
].map(([title, desc]) => ( ].map(([title, desc]) => (
<article key={title} style={{ display: "flex", alignItems: "flex-start", gap: "0.75rem", borderRadius: "0.75rem", border: "1px solid rgba(255,255,255,0.15)", background: "rgba(255,255,255,0.1)", padding: "0.85rem" }}> <article
<div aria-hidden="true" style={{ marginTop: "0.125rem", flexShrink: 0, width: "2.25rem", height: "2.25rem", borderRadius: "0.6rem", background: "rgba(255,255,255,0.15)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.95rem" }}> key={title}
style={{
display: "flex",
alignItems: "flex-start",
gap: "0.75rem",
borderRadius: "0.75rem",
border: "1px solid rgba(255,255,255,0.15)",
background: "rgba(255,255,255,0.1)",
padding: "0.85rem",
}}
>
<div
aria-hidden="true"
style={{
marginTop: "0.125rem",
flexShrink: 0,
width: "2.25rem",
height: "2.25rem",
borderRadius: "0.6rem",
background: "rgba(255,255,255,0.15)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.95rem",
}}
>
</div> </div>
<div> <div>
<h2 style={{ fontSize: "0.9rem", fontWeight: 600, margin: 0 }}>{title}</h2> <h2
<p style={{ marginTop: "0.25rem", fontSize: "0.8rem", color: "rgba(255,255,255,0.8)" }}>{desc}</p> style={{
fontSize: "0.9rem",
fontWeight: 600,
margin: 0,
}}
>
{title}
</h2>
<p
style={{
marginTop: "0.25rem",
fontSize: "0.8rem",
color: "rgba(255,255,255,0.8)",
}}
>
{desc}
</p>
</div> </div>
</article> </article>
))} ))}
</div> </div>
</div> </div>
<p style={{ fontSize: "0.75rem", color: "rgba(255,255,255,0.5)" }}>مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.</p> <p style={{ fontSize: "0.75rem", color: "rgba(255,255,255,0.5)" }}>
مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.
</p>
</div> </div>
</aside> </aside>
<section style={{ display: "flex", alignItems: "center", justifyContent: "center", background: "var(--color-surface)", padding: "2.5rem 1.5rem" }}> <section
<form onSubmit={handleSubmit(onSubmit)} style={{ width: "100%", maxWidth: "26rem", borderRadius: "0.9rem", border: "1px solid var(--color-border)", background: "#FFF", padding: "2rem", boxShadow: "var(--shadow-card)" }} noValidate> style={{
<p style={{ fontSize: "1.75rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>مرحبًا بعودتك</p> display: "flex",
<p style={{ marginTop: "0.35rem", fontSize: "0.9rem", color: "var(--color-text-muted)" }}>سجل الدخول لإدارة عمليات التسليم</p> alignItems: "center",
justifyContent: "center",
background: "var(--color-surface)",
padding: "2.5rem 1.5rem",
}}
>
<form
onSubmit={handleSubmit(onSubmit)}
style={{
width: "100%",
maxWidth: "26rem",
borderRadius: "0.9rem",
border: "1px solid var(--color-border)",
background: "#FFF",
padding: "2rem",
boxShadow: "var(--shadow-card)",
}}
noValidate
>
<p
style={{
fontSize: "1.75rem",
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
مرحبًا بعودتك
</p>
<p
style={{
marginTop: "0.35rem",
fontSize: "0.9rem",
color: "var(--color-text-muted)",
}}
>
سجل الدخول لإدارة عمليات التسليم
</p>
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}> <div
style={{
marginTop: "1.5rem",
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
<Input <Input
label="البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم" label="البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم"
autoComplete="username" autoComplete="username"
@@ -84,8 +252,23 @@ export function LoginForm() {
/> />
</div> </div>
<div style={{ marginTop: "0.75rem", display: "flex", justifyContent: "flex-end" }}> <div
<Link href="/forgot-password" style={{ fontSize: "0.8rem", color: "var(--color-brand-600)", textDecoration: "none" }}> style={{
marginTop: "0.75rem",
display: "flex",
justifyContent: "flex-end",
}}
>
<Link
href="/forgot-password"
style={{
fontSize: "0.8rem",
color: "var(--color-brand-600)",
textDecoration: "none",
pointerEvents: "none",
opacity: 0.5,
}}
>
هل نسيت كلمة المرور؟ هل نسيت كلمة المرور؟
</Link> </Link>
</div> </div>
@@ -96,13 +279,35 @@ export function LoginForm() {
</div> </div>
)} )}
<Button type="submit" loading={loading} fullWidth className="mt-6 h-11"> <Button
type="submit"
loading={loading}
fullWidth
className="mt-6 h-11"
>
{loading ? "جاري تسجيل الدخول…" : "تسجيل الدخول"} {loading ? "جاري تسجيل الدخول…" : "تسجيل الدخول"}
</Button> </Button>
<p style={{ marginTop: "1rem", textAlign: "center", fontSize: "0.85rem", color: "var(--color-text-muted)" }}> <p
style={{
marginTop: "1rem",
textAlign: "center",
fontSize: "0.85rem",
color: "var(--color-text-muted)",
}}
>
ليس لديك حساب؟{" "} ليس لديك حساب؟{" "}
<Link href="/register" style={{ color: "var(--color-brand-600)", textDecoration: "none" }}> <Link
href="/register"
style={{
color: "var(--color-brand-600)",
textDecoration: "none",
pointerEvents: "none",
opacity: 0.5,
}}
aria-disabled="true"
tabIndex={-1}
>
إنشاء حساب إنشاء حساب
</Link> </Link>
</p> </p>

View File

@@ -0,0 +1,86 @@
"use client";
import { useEffect } from "react";
import { Spinner } from "../UI";
import type { Car } from "@/src/types/car";
interface CarDeleteModalProps {
car: Car;
deleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function CarDeleteModal({ car, deleting, onCancel, onConfirm }: CarDeleteModalProps) {
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onCancel]);
return (
<div
role="alertdialog" aria-modal="true" aria-labelledby="car-del-title"
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
style={{
position: "fixed", inset: 0, zIndex: 60,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 420,
background: "var(--color-surface)",
borderRadius: "var(--radius-xl)",
border: "1px solid #FECACA",
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
padding: "2rem",
display: "flex", flexDirection: "column", gap: "1rem",
textAlign: "center",
}}
>
{/* Icon */}
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</div>
<div>
<h2 id="car-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
حذف المركبة
</h2>
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
هل أنت متأكد من حذف{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{car.manufacturer} {car.model}
</strong>
{" "}(
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
{car.plateLetters} {car.plateNumber}
</span>
)؟ لا يمكن التراجع عن هذا الإجراء.
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={onCancel} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء
</button>
<button type="button" onClick={onConfirm} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
{deleting && <Spinner size="sm" className="text-white" />}
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -229,11 +229,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
{/* Row 1: secondary views */} {/* Row 1: secondary views */}
<div style={{ display: "flex", gap: "0.75rem" }}> <div style={{ display: "flex", gap: "0.75rem" }}>
<Button type="button" variant="secondary" style={{ flex: 1 }} onClick={() => setGallery(true)}> <Button type="button" variant="secondary" style={{ flex: 1 }} onClick={() => setGallery(true)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-photo" style={{ fontSize: 16 }} aria-hidden="true" />
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
الصور الصور
</Button> </Button>
<Button <Button
@@ -242,9 +238,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
style={{ flex: 1 }} style={{ flex: 1 }}
onClick={() => router.push(`/dashboard/cars/${car.id}/maintenance`)} onClick={() => router.push(`/dashboard/cars/${car.id}/maintenance`)}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-tool" style={{ fontSize: 16 }} aria-hidden="true" />
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
عرض تاريخ الصيانة عرض تاريخ الصيانة
</Button> </Button>
</div> </div>
@@ -252,10 +246,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
{/* Row 2: primary edit / delete */} {/* Row 2: primary edit / delete */}
<div style={{ display: "flex", gap: "0.75rem" }}> <div style={{ display: "flex", gap: "0.75rem" }}>
<Button type="button" variant="primary" style={{ flex: 2 }} onClick={() => onEdit(car)}> <Button type="button" variant="primary" style={{ flex: 2 }} onClick={() => onEdit(car)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
تعديل تعديل
</Button> </Button>
<Button type="button" variant="danger" onClick={() => onDelete(car)}> <Button type="button" variant="danger" onClick={() => onDelete(car)}>

View File

@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, Input, Modal, Select } from "../UI"; import { Alert, Button, Input, Modal, Select } from "../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { useEditFormSync } from "@/src/hooks/useEditFormSync";
import { import {
createCarSchema, createCarSchema,
updateCarSchema, updateCarSchema,
@@ -134,38 +135,31 @@ export function CarFormModal({
}, },
}); });
// ── Local branches (auto-fetched if prop is empty) ───────────────────────── // ── Local branches (auto-fetched if prop is empty) ─────────────────────────
const [branches, setBranches] = useState<Branch[]>(branchesProp); const [branches, setBranches] = useState<Branch[]>(branchesProp);
const loadBranches = useCallback(() => { const loadBranches = useCallback(() => {
// defaultValues.branchId was applied before this list existed, so the if (branchesProp.length > 0) {
// <select> had no matching <option> yet and silently fell back to "" — queueMicrotask(() => setBranches(branchesProp));
// reapply the saved id now that the option actually exists in the DOM. return;
const savedBranchId = editCar?.branch?.id; }
const token = getStoredToken();
branchService
.getOptions(token)
.then((list) => {
queueMicrotask(() => setBranches(list as unknown as Branch[]));
})
.catch(() => {
/* silently ignore */
});
}, [branchesProp]);
if (branchesProp.length > 0) { useEffect(() => {
queueMicrotask(() => { loadBranches();
setBranches(branchesProp); }, [loadBranches]);
if (savedBranchId) setValue("branchId", savedBranchId);
});
return;
}
const token = getStoredToken();
branchService
.getOptions(token)
.then((list) => {
queueMicrotask(() => {
setBranches(list as unknown as Branch[]);
if (savedBranchId) setValue("branchId", savedBranchId);
});
})
.catch(() => {
/* silently ignore */
});
}, [branchesProp, editCar, setValue]);
useEffect(() => { // CHANGE: replaced the manual `if (savedBranchId) setValue("branchId", ...)`
loadBranches(); // calls above with useEditFormSync.
}, [loadBranches]); useEditFormSync(setValue, "branchId", editCar?.branch?.id, branches);
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");

View File

@@ -120,9 +120,7 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
loading={uploading} loading={uploading}
> >
{!uploading && ( {!uploading && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-upload" style={{ fontSize: 14 }} aria-hidden="true" />
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
)} )}
{uploading ? "جارٍ الرفع…" : "رفع صور"} {uploading ? "جارٍ الرفع…" : "رفع صور"}
</Button> </Button>
@@ -224,10 +222,7 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
> >
{isDeleting {isDeleting
? <Spinner size="sm" className="text-red-600" /> ? <Spinner size="sm" className="text-red-600" />
: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> : <i className="ti ti-trash" style={{ fontSize: 12 }} aria-hidden="true" />
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
</svg>
} }
</IconBtn> </IconBtn>
</div> </div>

View File

@@ -48,9 +48,7 @@ export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) {
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
dir="rtl" dir="rtl"
icon={ icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
} }
/> />
</div> </div>

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