Compare commits

...

6 Commits

104 changed files with 6625 additions and 4072 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,16 +8,11 @@ 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;

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

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

@@ -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,29 +108,11 @@ 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 <section
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
> >
@@ -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 DetailRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { function MaintenanceCard({
return ( record, onView, onEdit, onDelete,
<div style={{ }: {
display: "flex", justifyContent: "space-between", alignItems: "baseline", record: CarMaintenance;
padding: "0.75rem 0", borderBottom: "1px solid var(--color-border)", onView: () => void;
}}> onEdit: () => void;
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>{label}</span> onDelete: () => void;
<span style={{ fontSize: 14, fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)", color: "var(--color-text-primary)" }}> }) {
{value}
</span>
</div>
);
}
export default function CarMaintenanceDetailPage() {
const { carId, maintenanceId } = useParams<{ carId: string; maintenanceId: string }>();
const router = useRouter();
// NOTE: GET /cars/:carId/maintenance/:maintenanceId isn't wired up on the
// backend yet (returns "Route not found"), so this page resolves the
// record from the already-loaded list instead of a dedicated single-record
// call. includeDeleted: true so an archived record still resolves to a
// real "مؤرشفة" status instead of silently disappearing.
const { records, loading, error } = useCarMaintenanceList(carId, { includeDeleted: true });
const record = records.find((r) => r.id === maintenanceId) ?? null;
return (
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem", maxWidth: 720, margin: "0 auto" }} dir="rtl">
<PageHeader
title="تفاصيل سجل الصيانة"
description={record?.reason}
backHref={`/dashboard/cars/${carId}/maintenance`}
backLabel="العودة"
/>
<div style={{
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", boxShadow: "var(--shadow-card)", padding: "1.5rem",
}}>
{loading && (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>جارٍ التحميل</span>
</div>
)}
{error && <Alert type="error" message={error} />}
{!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)]; const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)];
return ( return (
<> <article style={{
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}> borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
<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 }}> background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
}}>
<div style={{ height: 4, background: status.dot }} />
<div style={{ padding: "1.25rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
<p style={{ fontSize: 15, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
{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} {status.label}
</span> </span>
</div> </div>
<DetailRow label="سبب الصيانة" value={record.reason} /> <div style={{ marginTop: "0.875rem", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}>
<DetailRow label="التكلفة" value={fmtCost(record.cost)} /> <div style={{ fontSize: 11 }}>
<DetailRow label="تاريخ البدء" value={fmtDate(record.startAt)} /> <span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>التكلفة</span>
<DetailRow label="تاريخ الانتهاء" value={fmtDate(record.endAt)} /> <span style={{ color: "var(--color-text-primary)", marginTop: 2, display: "block" }}>{fmtCost(record.cost)}</span>
<DetailRow label="المدة" value={`${durationDays(record.startAt, record.endAt)} يوم`} /> </div>
{record.car && ( <div style={{ fontSize: 11 }}>
<DetailRow label="المركبة" value={`${record.car.manufacturer} ${record.car.model}${record.car.plateLetters} ${record.car.plateNumber}`} mono /> <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>
<DetailRow label="تاريخ الإضافة" value={fmtDate(record.createdAt)} /> </div>
<DetailRow label="آخر تحديث" value={fmtDate(record.updatedAt)} /> <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>
<div> <div style={{ display: "flex", justifyContent: "space-between", marginTop: "1rem" }}>
<button type="button" onClick={() => router.push(`/dashboard/cars/${carId}/maintenance`)} <Button variant="secondary" size="sm" onClick={onEdit}>
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>
</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> </div>
</section> </article>
);
}
export default function CarMaintenancePage() {
const { carId } = useParams<{ carId: string }>();
const router = useRouter();
const { car, refetch: refetchCar } = useCarDetail(carId);
const carLabel = car ? `${car.manufacturer} ${car.model}${car.plateLetters} ${car.plateNumber}` : undefined;
const { records, loading, error, loadRecords, removeRecord, setError } = useCarMaintenanceList(carId);
const { toast, notify } = useMaintenanceToast();
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 (
<>
<Toast notification={toast} />
{formTarget !== false && (
<CarMaintenanceFormModal
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) : ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
/>
{archiveOpen && (
<ArchivedCarsMaintenanceModal
carId={carId}
carLabel={carLabel}
onClose={() => setArchiveOpen(false)}
/>
)}
<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>
)}
</section>
<ArchiveButton onClick={() => setArchiveOpen(true)} label="أرشيف الصيانة" />
</>
); );
} }

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

@@ -11,7 +11,6 @@ import { useCarMaintenanceMutations } from "@/src/hooks/UseCarsMaintanance";
import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car"; import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car";
import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/src/types/car"; import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/src/types/car";
import type { CreateMaintenancePayload, UpdateMaintenancePayload } from "@/src/types/carMaintanance"; import type { CreateMaintenancePayload, UpdateMaintenancePayload } from "@/src/types/carMaintanance";
import Header from "@/src/Components/UI/Header";
// ── Toast ───────────────────────────────────────────────────────────────────── // ── Toast ─────────────────────────────────────────────────────────────────────
@@ -42,6 +41,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,
@@ -74,23 +77,7 @@ function CarCard({
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 style={{
@@ -195,9 +182,7 @@ function CarCard({
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 className="ti ti-tool" style={{ fontSize: 13 }} 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>
@@ -309,7 +294,75 @@ export default function CarsPage() {
<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)} />}
@@ -357,6 +410,14 @@ export default function CarsPage() {
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: "1rem", 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}

View File

@@ -1,79 +1,203 @@
// 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 { AddressFormModal } from "@/src/Components/Client";
import { AddressDetails } from "@/src/Components/Client_Adress/AddressDetails"; import { AddressDetails } from "@/src/Components/Client_Adress/AddressDetails";
import { clientAddressService } from "@/src/services/clientAddress.service"; import { clientAddressService } from "@/src/services/clientAddress.service";
import { clientService } from "@/src/services/client.service";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import type { ClientAddress } from "@/src/types/client_adresses";
export default function AddressDetailsPage() { import type { Client } from "@/src/types/client";
import type { ClientAddress } from "@/src/types/client_adresses";
import type { ToastNotification } from "@/src/Components/UI";
import type {
CreateAddressFormValues,
UpdateAddressFormValues,
} from "@/src/validations/client_address.validator";
// ─── Page ──────────────────────────────────────────────────────────────────
export default function AddressDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const clientId = params?.clientId as string | undefined; const clientId = (params?.clientId ?? params?.id) as string | undefined;
const addressId = params?.addressesId 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" /> style={{
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>جارٍ تحميل بيانات العنوان</span> display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "60vh",
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: "50%",
border: "3px solid var(--color-border)",
borderTopColor: "var(--color-brand-600)",
animation: "spin 0.7s linear infinite",
}}
/>
</div> </div>
); );
} }
// ── Error ────────────────────────────────────────────────────────────────
if (error || !address) { if (error || !address) {
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem", maxWidth: 640, margin: "2rem auto" }}> <section style={{ padding: "2rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
<Alert type="error" message={error ?? "العنوان غير موجود."} /> <Alert type="error" message={error ?? "لم يتم العثور على العنوان."} />
<Button <button
type="button" type="button"
variant="secondary" onClick={() => router.back()}
onClick={() => router.push(`/dashboard/${clientId}/addresses`)} style={{
alignSelf: "flex-start",
fontSize: 13,
fontWeight: 600,
color: "var(--color-brand-600)",
background: "none",
border: "none",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}}
> >
العودة إلى العناوين رجوع
</Button> </button>
</div> </section>
); );
} }
// ── Content ────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
return ( return (
<section style={{ display: "flex", flexDirection: "column", gap: "1.25rem", maxWidth: 720, margin: "0 auto", padding: "2rem 1rem" }}> <>
<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 <button
type="button" type="button"
onClick={() => router.push(`/dashboard/${clientId}/addresses`)} onClick={() => router.push(`/dashboard/clients/${clientId}/addresses`)}
style={{ style={{
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
@@ -84,17 +208,132 @@ export default function AddressDetailsPage() {
background: "none", background: "none",
border: "none", border: "none",
cursor: "pointer", cursor: "pointer",
alignSelf: "flex-start", marginBottom: 12,
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 18l-6-6 6-6" /> <path d="M15 18l-6-6 6-6" />
</svg> </svg>
كل العناوين {client ? `${client.name} — العناوين` : "العناوين"}
</button> </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} /> <AddressDetails address={address} />
</section> </section>
</>
); );
} }

View File

@@ -8,6 +8,10 @@ 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";
@@ -18,6 +22,7 @@ import type { Client, ClientFormData } from "@/src/types/client";
import type { ClientAddress } from "@/src/types/client_adresses"; import type { ClientAddress } from "@/src/types/client_adresses";
import { AddressFormModal, ClientFormModal } from "@/src/Components/Client"; import { AddressFormModal, ClientFormModal } from "@/src/Components/Client";
import { AddressDetailModal } from "@/src/Components/Client_Adress/AddressDetailModal";
import { ArchivedClientsAddressesModal } from "@/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal"; import { ArchivedClientsAddressesModal } from "@/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal";
import type { import type {
CreateAddressFormValues, CreateAddressFormValues,
@@ -28,11 +33,11 @@ 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": "🏢",
@@ -43,6 +48,10 @@ function labelIcon(label: string): string {
} }
// ─── AddressCard ─────────────────────────────────────────────────────────── // ─── AddressCard ───────────────────────────────────────────────────────────
// CHANGE: clicking the card now opens AddressDetailModal — same pattern as
// UserTable → UserDetailModal (viewUserId). Action buttons stop propagation
// so they don't trigger the modal. `ActionBtn` (custom component) removed in
// favor of the shared <Button size="sm" /> from the template.
interface AddressCardProps { interface AddressCardProps {
address: ClientAddress; address: ClientAddress;
@@ -53,13 +62,7 @@ interface AddressCardProps {
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",
@@ -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,7 +203,7 @@ 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={{
@@ -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>
); );
@@ -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
>(false);
const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null); const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [editingClient, setEditingClient] = useState(false); const [editingClient, setEditingClient] = useState(false);
// Archive browser modal open/closed — scoped to this client's addresses // Archive browser modal open/closed — scoped to this client's addresses
const [archiveOpen, setArchiveOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false);
// Address whose detail modal is open — same pattern as viewUserId/UserDetailModal
const [viewAddress, setViewAddress] = useState<ClientAddress | null>(null);
// ── Address form handler ───────────────────────────────────────────────── // ── Address form handler ─────────────────────────────────────────────────
const handleAddressSubmit = async ( const handleAddressSubmit = async (
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)",
}}
>
<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> </Button>
</div> }
/>
) : ( ) : (
<div <div
style={{ style={{
@@ -803,22 +525,19 @@ export default function ClientAddressesPage() {
<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,7 +2,7 @@
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 { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver";
@@ -164,14 +164,9 @@ export default function DriverDetailPage() {
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 style={{ maxWidth: 480, margin: "4rem auto", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "1.5rem", textAlign: "center" }}>
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}> {error ?? "السائق غير موجود"}</p> <p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}> {error ?? "السائق غير موجود"}</p>
<button type="button" onClick={() => router.back()} style={{ <Button variant="secondary" size="sm" onClick={() => router.back()} className="mt-4">
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>
); );
} }
@@ -180,35 +175,16 @@ export default function DriverDetailPage() {
<> <>
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}> <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{toast && ( <Toast notification={toast} onDismiss={() => setToast(null)} />
<div style={{
borderRadius: "var(--radius-lg)",
border: `1px solid ${toast.type === "success" ? "#BBF7D0" : "#FECACA"}`,
background: toast.type === "success" ? "#DCFCE7" : "#FEF2F2",
padding: "0.75rem 1.25rem",
fontSize: 13,
fontWeight: 600,
color: toast.type === "success" ? "#166534" : "#DC2626",
}}>
{toast.type === "success" ? "✓ " : "⚠ "}{toast.message}
</div>
)}
<header style={{ <header style={{
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)", background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)",
}}> }}>
<button type="button" onClick={() => router.back()} style={{ <Button variant="ghost" size="sm" onClick={() => router.back()} className="mb-4 !px-0">
display: "inline-flex", alignItems: "center", gap: 6, <i className="ti ti-arrow-left" style={{ fontSize: 14 }} aria-hidden="true" />
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 }}>
@@ -249,25 +225,13 @@ 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 className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
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)",
}}>
<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>

View File

@@ -1,94 +1,21 @@
"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, Driver, UpdateDriverPayload } from "@/src/types/driver";
CreateDriverPayload, import { DriverDetailPanel, DriverFormModal , } from "@/src/Components/Driver";
Driver,
DRIVER_STATUS_MAP,
UpdateDriverPayload,
} from "@/src/types/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 ────────────────────────────────────────────────────────────
export default function DriversPage() { export default function DriversPage() {
const { const {
drivers, drivers, loading, error, total, pages, page,
loading, search, setPage, handleSearch, clearError,
error, createDriver, updateDriver, deleteDriver,
total,
pages,
page,
search,
setPage,
handleSearch,
clearError,
createDriver,
updateDriver,
deleteDriver,
notification, notification,
clearNotification,
} = useDrivers(); } = useDrivers();
// ── Panel / modal state ─────────────────────────────────────────────────── // ── Panel / modal state ───────────────────────────────────────────────────
@@ -157,321 +84,101 @@ export default function DriversPage() {
// ── Render ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
return ( return (
<> <>
<section <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
>
{/* ── Header ── */} {/* ── Header ── */}
<Header <header style={{
mainTitle="إدارة السائقين" borderRadius: "var(--radius-xl)",
title="السائقون" border: "1px solid var(--color-border)",
name="سائق" background: "var(--color-surface)",
state={{ total }} padding: "1.5rem 2rem",
search={search} boxShadow: "var(--shadow-card)",
setSearch={handleSearch} }}>
module="" <p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
setModule={() => {}} إدارة الكوادر
setPage={setPage}
isAudit={false}
onAdd={() => setFormDriver("new")}
/>
{/* ── Notifications ── */}
<Toast notification={notification} onDismiss={clearNotification} />
{error && <Alert type="error" message={error} onClose={clearError} />}
{/* ── Table ── */}
<div style={cardStyle}>
<div
dir="rtl"
style={{
display: "grid",
gridTemplateColumns: ROW_GRID_COLUMNS,
...thStyle,
}}
>
<span>السائق</span>
<span>الفرع</span>
<span>الجنسية</span>
<span>انتهاء الرخصة</span>
<span>انتهاء الهوية</span>
<span>الحالة</span>
<span>إجراءات</span>
</div>
{loading ? (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "4rem 0",
color: "var(--color-text-muted)",
}}
>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : drivers.length === 0 ? (
<p
style={{
textAlign: "center",
padding: "4rem 0",
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
لا توجد نتائج {search && `لـ "${search}"`}
</p> </p>
) : ( <div style={{ marginTop: "0.75rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> <div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
{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> <div>
<p <h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
style={{ السائقون
fontWeight: 600, </h1>
color: "var(--color-text-primary)", <p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
margin: 0, إجمالي{" "}
}} <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong>{" "}
> سائق مسجل
{d.name}
</p>
<p
style={{
marginTop: 2,
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--color-text-muted)",
}}
>
{d.phone}
</p> </p>
</div> </div>
<span style={{ color: "var(--color-text-secondary)" }}>
{d.branch?.name ?? ""} <div style={{ display: "flex", gap: "0.75rem", alignItems: "center", flexWrap: "wrap" }}>
</span> {/* Search */}
<span style={{ color: "var(--color-text-secondary)" }}> <div style={{ position: "relative", width: 288 }}>
{d.nationality ?? "—"} <i
</span> className="ti ti-search"
<span aria-hidden="true"
style={{ style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", fontSize: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
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} <input
</span> type="text"
placeholder="بحث بالاسم أو الهاتف..."
{/* ── Inline row actions: edit / delete ── value={search}
stopPropagation is required on both buttons so the onChange={(e) => handleSearch(e.target.value)}
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" dir="rtl"
style={{ style={{
display: "flex", width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
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)", borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)", background: "var(--color-surface)",
padding: "0.375rem 0.875rem", fontSize: 13, color: "var(--color-text-primary)",
fontSize: 12, outline: "none", fontFamily: "var(--font-sans)",
color: "var(--color-text-secondary)", }}
cursor: btn.disabled ? "not-allowed" : "pointer", />
opacity: btn.disabled ? 0.4 : 1, </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)", fontFamily: "var(--font-sans)",
flexShrink: 0,
}} }}
> >
{btn.label} <i className="ti ti-plus" style={{ fontSize: 15 }} aria-hidden="true" />
إضافة سائق
</button> </button>
))}
</div> </div>
</div> </div>
</div>
</header>
{/* ── Notifications ── */}
{notification && (
<Alert type={notification.type} message={notification.message} />
)} )}
</div> {error && (
<Alert type="error" message={error} onClose={clearError} />
)}
{/* ── Table ── */}
<DriverTable
drivers={drivers}
loading={loading}
search={search}
page={page}
pages={pages}
onPageChange={setPage}
onRowClick={(d) => setSelectedDriverId(d.id)}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</section> </section>
{/* ── Detail panel — key forces re-fetch after successful edit ── */} {/* ── Detail panel — key forces re-fetch after successful edit ── */}
@@ -506,7 +213,9 @@ export default function DriversPage() {
/> />
{/* ── Archive browser modal ── */} {/* ── Archive browser modal ── */}
{archiveOpen && <ArchivedDrivers onClose={() => setArchiveOpen(false)} />} {archiveOpen && (
<ArchivedDrivers onClose={() => setArchiveOpen(false)} />
)}
{/* ── Floating button to open the archive browser ── */} {/* ── Floating button to open the archive browser ── */}
<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,43 +67,25 @@ 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"> {/* ── Alerts Section: urgent cross-entity issues ── */}
{summaryCards.map((item) => ( <AlertsSection alerts={data?.alerts ?? []} loading={loading && !data} />
<article
key={item.label} {/* ── Insights Row: recent activity log + trend charts ── */}
style={{ <div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
borderRadius: "var(--radius-2xl)", <RecentActivityPanel logs={data?.recentActivity ?? []} loading={loading && !data} />
border: "1px solid var(--color-border-dark)", <TrendChartsPanel series={data?.trends ?? []} loading={loading && !data} />
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> </div>
{/* ── Active trips + alerts ── {/* ── Quick access to every entity page (minimizes navigation clicks) ── */}
Column order in the template below stays [trips, alerts] — <QuickAccessFooter />
same reading order as before. In RTL this now renders
trips on the right (read first) and alerts on the left, {/* ── Active trips (kept from the previous layout — live operational detail) ── */}
which is the correct mirror; no class change needed (see
grid + RTL note in the project report). */}
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<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)" }}> <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 className="flex items-center justify-between">
<div> <div>
@@ -136,14 +111,6 @@ export default function DashboardPage() {
</span> </span>
</div> </div>
<div style={{ marginTop: "0.75rem", height: 6, borderRadius: 999, background: "rgba(255,255,255,0.08)" }}> <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 style={{ height: 6, borderRadius: 999, width: `${trip.progress}%`, background: "linear-gradient(90deg,#06B6D4,#34D399)" }} />
</div> </div>
</div> </div>
@@ -153,32 +120,6 @@ export default function DashboardPage() {
</div> </div>
</article> </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: "#FCD34D", textAlign: "start" }}>تنبيهات الالتزام</p>
<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" }}>
{(alerts?.expiringCars || []).slice(0, 3).map((item, i) => (
<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" }}>
{String((item as Record<string, unknown>).message || "Vehicle expiry alert")}
</div>
))}
{(alerts?.expiringDrivers || []).slice(0, 3).map((item, i) => (
<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" }}>
{String((item as Record<string, unknown>).message || "Driver expiry alert")}
</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>
{/* ── Security + endpoints ── */} {/* ── Security + endpoints ── */}
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<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)" }}> <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)" }}>
@@ -189,14 +130,6 @@ export default function DashboardPage() {
آخر تسجيل دخول: <span className="ltr-embed">{data?.accountSecurity?.lastLogin || "—"}</span> آخر تسجيل دخول: <span className="ltr-embed">{data?.accountSecurity?.lastLogin || "—"}</span>
</div> </div>
<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 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" }}>
{/*
JSON.stringify output is structurally LTR (braces,
colons, commas read left-to-right regardless of
locale). Wrapping it in .ltr-embed prevents the
surrounding RTL paragraph from reordering the
punctuation — without this, nested objects can
render with their braces visually swapped.
*/}
بيانات الجهاز: <span className="ltr-embed">{data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"}</span> بيانات الجهاز: <span className="ltr-embed">{data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"}</span>
</div> </div>
</div> </div>
@@ -207,20 +140,12 @@ export default function DashboardPage() {
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>النقاط النهائية</h2> <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 }}> <ul style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem", listStyle: "none", padding: 0 }}>
{[ {[
"/v1/dashboard/summary — نظرة عامة تشغيلية", "/v1/dashboard/overview — نظرة عامة تشغيلية",
"/v1/client — إدارة العملاء", "/v1/client — إدارة العملاء",
"/v1/orders — دورة شحن الطلبات", "/v1/orders — دورة شحن الطلبات",
"/v1/trip — تنظيم الرحلات", "/v1/trip — تنظيم الرحلات",
].map((ep) => ( ].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" }}> <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> <span className="ltr-embed">{ep.split(" — ")[0]}</span>
{" — "} {" — "}
{ep.split(" — ")[1]} {ep.split(" — ")[1]}
@@ -229,8 +154,6 @@ export default function DashboardPage() {
</ul> </ul>
</article> </article>
</div> </div>
</>
)}
</section> </section>
); );
} }

View File

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

View File

@@ -1,17 +1,10 @@
"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";
@@ -304,25 +297,9 @@ 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 variant="secondary" size="sm" onClick={() => router.back()} style={{ marginTop: "1rem" }}>
type="button"
onClick={() => router.back()}
style={{
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>
); );
} }
@@ -346,29 +323,15 @@ export default function TripDetailPage() {
}} }}
> >
{/* Back link */} {/* Back link */}
<button <Button
type="button" variant="ghost"
size="sm"
onClick={() => router.back()} onClick={() => router.back()}
style={{ style={{ height: "auto", padding: 0, background: "none", marginBottom: "1rem" }}
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"> <i className="ti ti-arrow-left" style={{ fontSize: 14 }} aria-hidden="true" />
<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" }}>
{/* Icon + title */} {/* Icon + title */}
@@ -465,10 +428,7 @@ 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 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>
</div> </div>

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

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

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

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,21 +1,14 @@
"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;
@@ -29,106 +22,75 @@ interface BranchTableProps {
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,
return ( onEdit, onDelete, onView, onAddFirst, onPageChange,
<div style={cardStyle}> }: BranchTableProps) {
{/* column headers */} const columns: TableColumn<Branch>[] = [
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 100px", ...thStyle }}> {
<span>الفرع</span> key: "name",
<span>المدينة</span> header: "اسم الفرع",
<span>الهاتف</span> width: "2fr",
<span>الحالة</span> render: (b) => (
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<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>
) : branches.length === 0 ? (
/* empty state */
<EmptyState
icon="🏢"
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."}
action={
!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> <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p> <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> <p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
</div> </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"} /> { key: "city", header: "المدينة", width: "1.5fr", render: (b) => b.city || "—" },
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}> {
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} key: "phone",
header: "رقم الجوال",
width: "1.5fr",
render: (b) => (
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-secondary)" }}>
{b.phone ?? "—"}
</span> </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"> key: "status",
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> header: "الحالة",
<circle cx="12" cy="12" r="3" /> width: "1fr",
</svg> render: (b) => <Badge label={b.isActive ? "Active" : "Disabled"} color={b.isActive ? "green" : "red"} />,
</IconBtn> },
{/* edit */} {
<IconBtn title={`تعديل ${b.name}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(b)}> key: "createdAt",
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> header: "تاريخ الانشاء",
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /> width: "1fr",
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> align: "center",
</svg> render: (b) =>
</IconBtn> new Date(b.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
{/* 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 */} return (
{pages > 1 && ( <ReusableTable
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}> columns={columns}
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}> data={branches}
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> loading={loading}
</span> search={search}
<div style={{ display: "flex", gap: "0.5rem" }}> page={page}
{[ pages={pages}
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, onPageChange={onPageChange}
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, emptyIcon="🏢"
].map(btn => ( emptyDescription={!search ? "No branches to display." : undefined}
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}> emptyAction={
{btn.label} !search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
Add first branch
</Button> </Button>
))} )
</div> }
</div> renderActions={(b) => (
<ActionButtons
itemLabel={b.name}
onView={() => onView(b)}
onEdit={() => onEdit(b)}
onDelete={() => onDelete(b)}
/>
)} )}
</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,21 +1,13 @@
"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;
@@ -26,96 +18,53 @@ interface ArchivedBranchTableProps {
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) {
return ( const columns: TableColumn<ArchivedBranch>[] = [
<div style={cardStyle}> {
{/* column headers */} key: "name",
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> header: "اسم الفرع",
<span>الفرع</span> width: "2fr",
<span>المدينة</span> render: (b) => (
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>آخر تحديث</span>
<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>
) : 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> <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p> <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> <p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
</div> </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)" }}> { key: "city", header: "المدينة", width: "1.5fr", render: (b) => b.city || "—" },
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} {
</span> key: "status",
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}> header: "الحالة",
{new Date(b.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} width: "1fr",
</span> render: (b) => <Badge label={b.isActive ? "Active" : "Disabled"} color={b.isActive ? "green" : "red"} />,
<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)}> key: "createdAt",
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> header: "تاريخ الانشاء",
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> width: "1fr",
<circle cx="12" cy="12" r="3" /> align: "center",
</svg> render: (b) => new Date(b.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
</IconBtn> },
</div> {
</li> key: "updatedAt",
))} header: "تاريخ التعديل",
</ul> width: "1fr",
)} align: "center",
render: (b) => new Date(b.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
},
];
{/* pagination */} return (
{pages > 1 && ( <ReusableTable
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}> columns={columns}
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}> data={branches}
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> loading={loading}
</span> search={search}
<div style={{ display: "flex", gap: "0.5rem" }}> page={page}
<Button pages={pages}
variant="secondary" onPageChange={onPageChange}
size="sm" emptyDescription={search ? `No results for "${search}"` : "No archived branches."}
onClick={() => onPageChange(Math.max(1, page - 1))} renderActions={(b) => <ActionButtons itemLabel={b.name} onView={() => onView(b)} />}
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>[] = [
{
return ( key: "reason",
<div style={cardStyle}> header: "سبب الصيانة",
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: columns, ...thStyle }}> width: "2fr",
<span>سبب الصيانة</span> render: (r) => (
{showCar && <span>المركبة</span>}
<span>التكلفة</span>
<span>تاريخ البدء</span>
<span style={{ textAlign: "center" }}>تاريخ الانتهاء</span>
<span style={{ textAlign: "center" }}>إجراءات</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>
) : records.length === 0 ? (
<EmptyState
icon="🔧"
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."}
/>
) : (
<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> <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{r.reason}</p> <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> <p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>أضيف {fmtDateShort(r.createdAt)}</p>
</div> </div>
{showCar && ( ),
},
// 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)" }}> <span style={{ color: "var(--color-text-secondary)" }}>
{r.car ? `${r.car.manufacturer} ${r.car.model}` : "—"} {r.car ? `${r.car.manufacturer} ${r.car.model}` : "—"}
</span> </span>
)} ),
} as TableColumn<CarMaintenance>]
: []),
{
key: "cost",
header: "التكلفة",
width: "1fr",
render: (r) => (
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}> <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
{fmtCost(r.cost)} {fmtCost(r.cost)}
</span> </span>
),
},
{ key: "startAt", header: "تاريخ البدء", width: "1fr", render: (r) => (
<span style={{ color: "var(--color-text-secondary)" }}>{fmtDateShort(r.startAt)}</span> <span style={{ color: "var(--color-text-secondary)" }}>{fmtDateShort(r.startAt)}</span>
<span style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-muted)" }}> ),
},
{
key: "endAt",
header: "تاريخ الانتهاء",
width: "1fr",
render: (r) => (
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
{fmtDateShort(r.endAt)} {fmtDateShort(r.endAt)}
</span> </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 && ( return (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}> <ReusableTable
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}> columns={columns}
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> data={records}
</span> loading={loading}
<div style={{ display: "flex", gap: "0.5rem" }}> search={search}
{[ page={page}
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, pages={pages}
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, onPageChange={onPageChange}
].map(btn => ( actionsWidth="100px"
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}> onRowClick={onView}
{btn.label} emptyDescription={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."}
</Button> renderActions={(r) => (
))} <IconBtn
</div> title={`عرض ${r.reason}`}
</div> color="#059669" bg="#ECFDF5" borderColor="#A7F3D0"
onClick={() => onView(r)}
>
<i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
</IconBtn>
)} )}
</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,107 +258,12 @@ 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) => {
// Toggle: collapse if already open, expand otherwise
setExpandedId((prev) => (prev === client.id ? null : client.id));
};
return (
<div style={cardStyle}>
{/* Column headers */}
<div
dir="rtl"
style={{
display: "grid",
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
...thStyle,
}}
>
<span>العميل</span>
<span>البريد الإلكتروني</span>
<span>الهاتف</span>
<span>العناوين</span>
<span style={{ textAlign: "center" }}>تاريخ الإضافة</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* Loading */}
{loading ? (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "4rem 0",
color: "var(--color-text-muted)",
}}
>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : clients.length === 0 ? (
/* Empty state */
<EmptyState
icon="👥"
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء لعرضهم."}
action={
!search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
أضف أول عميل
</Button>
)
}
/>
) : (
/* 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> <div>
<p <p
style={{ style={{
@@ -399,23 +276,18 @@ export function ClientTable({
}} }}
> >
{c.name} {c.name}
{/* Chevron indicator — rotates when expanded */} <i
<svg className="ti ti-chevron-right"
width="12" aria-hidden="true"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
style={{ style={{
fontSize: 12,
flexShrink: 0, flexShrink: 0,
color: "var(--color-text-muted)", color: "var(--color-text-muted)",
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)", transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
transition: "transform 200ms", transition: "transform 200ms",
display: "inline-block",
}} }}
> />
<path d="M9 18l6-6-6-6" />
</svg>
</p> </p>
{c.taxId && ( {c.taxId && (
<p <p
@@ -430,8 +302,13 @@ export function ClientTable({
</p> </p>
)} )}
</div> </div>
),
{/* Email */} },
{
key: "email",
header: "البريد الالكتروني",
width: "1.5fr",
render: (c) => (
<span <span
style={{ style={{
fontFamily: "var(--font-mono)", fontFamily: "var(--font-mono)",
@@ -442,155 +319,94 @@ export function ClientTable({
> >
{c.email} {c.email}
</span> </span>
),
{/* Phone */} },
<span style={{ color: "var(--color-text-secondary)" }}> {
{c.phone} key: "phone",
</span> header: "رقم الجوال",
width: "1.5fr",
{/* render: (c) => (
* Address count badge — clicking it navigates directly to <span style={{ color: "var(--color-text-secondary)" }}>{c.phone}</span>
* /dashboard/clients/[clientId]/addresses (bypasses the detail panel). ),
*/} },
{
key: "addresses",
header: "العناوين",
width: "1fr",
render: (c) => (
<AddressBadge <AddressBadge
count={c.addresses?.length ?? 0} count={c.addresses?.length ?? 0}
onClick={() => onManageAddresses(c)} onClick={() => onManageAddresses(c)}
/> />
),
{/* Created at */} },
<span {
style={{ key: "createdAt",
textAlign: "center", header: "تاريخ الانشاء",
fontSize: 11, width: "1fr",
color: "var(--color-text-muted)", align: "center",
}} render: (c) => (
> <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(c.createdAt).toLocaleDateString("ar-SA", { {new Date(c.createdAt).toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
})} })}
</span> </span>
),
},
];
{/* Row action buttons */} return (
<div <ReusableTable
style={{ columns={columns}
display: "flex", data={clients}
justifyContent: "center", loading={loading}
gap: 6, search={search}
}} page={page}
onClick={(e) => e.stopPropagation()} pages={pages}
> onPageChange={onPageChange}
actionsWidth="80px"
emptyIcon="👥"
emptyTitle={
search ? `No results for "${search}"` : "No clients to display."
}
emptyAction={
!search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
Add first client
</Button>
)
}
renderActions={(c) => (
<>
<IconBtn <IconBtn
title={`تعديل ${c.name}`} title={`Edit ${c.name}`}
color="#1D4ED8" color="#1D4ED8"
bg="#EFF6FF" bg="#EFF6FF"
borderColor="#BFDBFE" borderColor="#BFDBFE"
onClick={() => onEdit(c)} onClick={() => onEdit(c)}
> >
<svg <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
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>
<IconBtn <IconBtn
title={`حذف ${c.name}`} title={`Delete ${c.name}`}
color="#DC2626" color="#DC2626"
bg="#FEF2F2" bg="#FEF2F2"
borderColor="#FECACA" borderColor="#FECACA"
onClick={() => onDelete(c)} onClick={() => onDelete(c)}
> >
<svg <i className="ti ti-trash" style={{ fontSize: 14 }} aria-hidden="true" />
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> </IconBtn>
</div> </>
</div> )}
renderExpanded={(c) => (
{/*
* ── 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 <ClientDetailPanel
client={c} client={c}
onGoAddresses={() => onManageAddresses(c)} onGoAddresses={() => onManageAddresses(c)}
onEdit={() => onEdit(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,21 +1,13 @@
"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;
@@ -26,87 +18,59 @@ interface ArchivedClientTableProps {
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) {
return ( const columns: TableColumn<ArchivedClient>[] = [
<div style={cardStyle}> {
{/* column headers */} key: "name",
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> header: "اسم العميل",
<span>العميل</span> width: "2fr",
<span>البريد الإلكتروني</span> render: (c) => (
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>آخر تحديث</span>
<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>
) : 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> <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{c.name}</p> <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> <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{c.phone}</p>
</div> </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" })} key: "email",
</span> header: "البريد الالكتروني",
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}> width: "1.5fr",
{new Date(c.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} render: (c) => <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{c.email}</span>,
</span> },
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}> {
{/* view only — delete/restore not implemented yet, matches archived users */} key: "status",
<IconBtn title={`عرض ${c.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(c)}> header: "الحالة",
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> width: "1fr",
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> render: (c) => <Badge label={c.isActive ? "Active" : "Disabled"} color={c.isActive ? "green" : "red"} />,
<circle cx="12" cy="12" r="3" /> },
</svg> {
</IconBtn> key: "createdAt",
</div> header: "تاريخ الانشاء",
</li> width: "1fr",
))} align: "center",
</ul> 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" }),
},
];
{/* pagination */} return (
{pages > 1 && ( <ReusableTable
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}> columns={columns}
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}> data={clients}
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> loading={loading}
</span> search={search}
<div style={{ display: "flex", gap: "0.5rem" }}> page={page}
{[ pages={pages}
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, onPageChange={onPageChange}
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, emptyIcon="👥"
].map(btn => ( emptyDescription={search ? `No results for "${search}"` : "No archived clients."}
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}> renderActions={(c) => <ActionButtons itemLabel={c.name} onView={() => onView(c)} />}
{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",
</div> render: (a) => (
) : addresses.length === 0 ? (
/* empty state */
<EmptyState
icon="📍"
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
/>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{addresses.map((a, i) => (
<li key={a._id} style={{
display: "grid", gridTemplateColumns: "1.5fr 2fr 1.2fr 1fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div> <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{a.label}</p> <p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{a.label}</p>
{a.branchName && ( {a.branchName && (
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{a.branchName}</p> <p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{a.branchName}</p>
)} )}
</div> </div>
),
},
{
key: "address",
header: "العنوان",
width: "2fr",
render: (a) => (
<span style={{ color: "var(--color-text-secondary)" }}> <span style={{ color: "var(--color-text-secondary)" }}>
{a.details.street}، {a.details.city} {a.details.street}، {a.details.city}
</span> </span>
),
},
{
key: "contact",
header: "جهة الاتصال",
width: "1.2fr",
render: (a) => (
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}> <span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
{a.contactPerson?.name ?? "—"} {a.contactPerson?.name ?? "—"}
</span> </span>
),
},
{
key: "status",
header: "الحالة",
width: "1fr",
render: (a) => (
<Badge <Badge
label={a.isValidated ? "موثّق" : "غير موثّق"} label={a.isValidated ? "موثّق" : "غير موثّق"}
color={a.isValidated ? "green" : "slate"} color={a.isValidated ? "green" : "slate"}
/> />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}> ),
},
{
key: "createdAt",
header: "تاريخ الإنشاء",
width: "1fr",
render: (a) => (
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} {new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span> </span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}> ),
{/* view only — matches archived users/clients: no delete/restore yet */} },
<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">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> return (
<circle cx="12" cy="12" r="3" /> <ReusableTable
</svg> columns={columns}
data={rows}
loading={loading}
search={search}
page={1}
pages={1}
onPageChange={() => {}}
actionsWidth="100px"
onRowClick={onView}
emptyDescription={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
renderActions={(a) => (
// view only — matches archived users/clients: no delete/restore yet
<IconBtn
title={`عرض ${a.label}`}
color="#059669" bg="#ECFDF5" borderColor="#A7F3D0"
onClick={() => onView(a)}
>
<i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
</IconBtn> </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,121 @@
"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>
);
}
function KpiCardCounts({ total, active, pending }: { total: number; active: number; pending: 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 style={{ marginTop: "0.625rem", display: "flex", gap: "0.75rem" }}>
<div style={{ flex: 1, borderRadius: "var(--radius-md)", background: "rgba(52,211,153,0.08)", border: "1px solid rgba(52,211,153,0.2)", padding: "0.375rem 0.625rem" }}>
<p style={{ fontSize: 10, color: "#6EE7B7", margin: 0 }}>نشط</p>
<p style={{ fontSize: 13, fontWeight: 700, color: "#A7F3D0", margin: 0, fontFamily: "var(--font-mono)" }}>
{active.toLocaleString("ar-SA")}
</p>
</div>
<div style={{ flex: 1, borderRadius: "var(--radius-md)", background: "rgba(251,191,36,0.08)", border: "1px solid rgba(251,191,36,0.2)", padding: "0.375rem 0.625rem" }}>
<p style={{ fontSize: 10, color: "#FCD34D", margin: 0 }}>معلَّق</p>
<p style={{ fontSize: 13, fontWeight: 700, color: "#FDE68A", margin: 0, fontFamily: "var(--font-mono)" }}>
{pending.toLocaleString("ar-SA")}
</p>
</div>
</div>
</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>
<KpiCardCounts total={entity.total} active={entity.active} pending={entity.pending} />
<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

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

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,38 +1,12 @@
"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;
@@ -43,100 +17,70 @@ interface ArchivedDriverTableProps {
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>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>آخر تحديث</span>
<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>
) : drivers.length === 0 ? (
/* empty state */
<EmptyState
icon="🗄️"
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد سائقون في الأرشيف."}
/>
) : (
/* data rows */
<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,
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",
}}>
<div> <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p> <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> <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
</div> </div>
),
},
{
key: "userName",
header: "اسم السائق",
width: "1.5fr",
render: (d) => (
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{d.userName ?? "—"}</span> <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" })} {
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 }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
{s.label}
</span> </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 */} key: "createdAt",
<IconBtn title={`عرض ${d.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(d)}> header: "تاريخ الانشاء",
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> width: "1fr",
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> align: "center",
<circle cx="12" cy="12" r="3" /> render: (d) => new Date(d.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
</svg> },
</IconBtn> {
</div> key: "updatedAt",
</li> header: "تاريخ التعديل",
))} width: "1fr",
</ul> align: "center",
)} render: (d) => new Date(d.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
},
];
{/* pagination */} return (
{pages > 1 && ( <ReusableTable
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}> columns={columns}
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}> data={drivers}
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> loading={loading}
</span> search={search}
<div style={{ display: "flex", gap: "0.5rem" }}> page={page}
<Button pages={pages}
type="button" onPageChange={onPageChange}
variant="secondary" onRowClick={onView}
size="sm" emptyIcon="🗄️"
onClick={() => onPageChange(Math.max(1, page - 1))} emptyDescription={search ? `No results for "${search}"` : "No archived drivers."}
disabled={page === 1} renderActions={(d) => <ActionButtons itemLabel={d.name} onView={() => onView(d)} />}
> />
السابق
</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 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,10 +372,7 @@ 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,
...thStyle,
}}
>
<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>
) : orders.length === 0 ? (
<p style={{ textAlign: "center", padding: "4rem 0", fontSize: 13, color: "var(--color-text-muted)" }}>
لا توجد نتائج {search && `لـ "${search}"`}
</p>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{orders.map((o, i) => {
const statusCfg = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
return (
<li
key={o.id}
onClick={() => onRowClick(o.id)}
style={{
display: "grid",
gridTemplateColumns: ROW_GRID_COLUMNS,
alignItems: "center", gap: "0.5rem",
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
cursor: "pointer",
transition: "background 0.15s",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--color-surface-hover, #F8FAFC)")}
onMouseLeave={(e) => (e.currentTarget.style.background = i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent")}
>
<div> <div>
<p style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB", margin: 0 }}>{o.shipmentNumber}</p> <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> <p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(o.createdAt)}</p>
</div> </div>
),
},
{
key: "recipient",
header: "المستلم",
width: "1.6fr",
render: (o) => (
<div> <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p> <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> <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
</div> </div>
<span style={{ color: "var(--color-text-secondary)" }}>{o.client?.name ?? "—"}</span> ),
},
{ key: "client", header: "العميل", width: "1.2fr", render: (o) => o.client?.name ?? "—" },
{
key: "amount",
header: "الكمية",
width: "1.1fr",
render: (o) => (
<span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}> <span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}>
{fmtAmount(o.totalPrice)} {fmtAmount(o.totalPrice)}
</span> </span>
),
},
{
key: "status",
header: "الحالة",
width: "1fr",
render: (o) => {
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
return (
<span style={{ <span style={{
display: "inline-flex", alignItems: "center", gap: 5, display: "inline-flex", alignItems: "center", gap: 5,
borderRadius: "var(--radius-full)", borderRadius: "var(--radius-full)",
border: `1px solid ${statusCfg.border}`, border: `1px solid ${s.border}`,
background: statusCfg.bg, background: s.bg,
padding: "0.2rem 0.625rem", padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 600, color: statusCfg.color, fontSize: 11, fontWeight: 600, color: s.color,
width: "fit-content", width: "fit-content",
}}> }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusCfg.dot, flexShrink: 0 }} /> <span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot, flexShrink: 0 }} />
{statusCfg.label} {s.label}
</span> </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 ── */} return (
{pages > 1 && ( <ReusableTable
<div columns={columns}
dir="rtl" data={orders}
style={{ loading={loading}
display: "flex", alignItems: "center", justifyContent: "space-between", search={search}
borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem", page={page}
}} pages={pages}
> onPageChange={setPage}
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}> actionsWidth="0.8fr"
صفحة{" "} onRowClick={(o) => onRowClick(o.id)}
<strong style={{ color: "var(--color-text-primary)" }}>{page}</strong>{" "} emptyDescription={search ? `No results for "${search}"` : undefined}
من{" "} renderActions={(o) => (
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> <ActionButtons
</span> itemLabel={o.shipmentNumber}
<div style={{ display: "flex", gap: "0.5rem" }}> onEdit={() => onEdit(o)}
{[ onDelete={() => onDelete(o)}
{ 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> />
); );
} }

View File

@@ -1,29 +1,17 @@
"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;
@@ -35,98 +23,58 @@ interface ArchivedOrderTableProps {
} }
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" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : orders.length === 0 ? (
<EmptyState
icon="🗄️"
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد طلبات في الأرشيف."}
/>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{orders.map((o, i) => {
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
return (
<li key={o.id} style={{
display: "grid", gridTemplateColumns: ROW_GRID,
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,
}}>
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB" }}>
{o.shipmentNumber}
</span>
<div> <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p> <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> <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
</div> </div>
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "var(--color-text-primary)" }}> ),
{fmtAmount(o.subTotal)} },
</span> {
<span style={{ key: "amount",
display: "inline-flex", alignItems: "center", gap: 5, header: "الكمية",
borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, width: "1fr",
background: s.bg, padding: "0.2rem 0.625rem", render: (o) => <span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "var(--color-text-primary)" }}>{fmtAmount(o.subTotal)}</span>,
fontSize: 11, fontWeight: 600, color: s.color, width: "fit-content", },
}}> {
key: "status",
header: "الحالة",
width: "1fr",
render: (o) => {
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: s.color, width: "fit-content" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} /> <span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
{s.label} {s.label}
</span> </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 && ( return (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}> <ReusableTable
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}> columns={columns}
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> data={orders}
</span> loading={loading}
<div style={{ display: "flex", gap: "0.5rem" }}> search={search}
<Button page={page}
type="button" pages={pages}
variant="secondary" onPageChange={onPageChange}
size="sm" emptyIcon="🗄️"
onClick={() => onPageChange(Math.max(1, page - 1))} emptyDescription={search ? `No results for "${search}"` : "No archived orders."}
disabled={page === 1} renderActions={(o) => <ActionButtons itemLabel={o.shipmentNumber} onView={() => onView(o)} />}
> />
السابق
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => onPageChange(Math.min(pages, page + 1))}
disabled={page === pages}
>
التالي
</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,26 +20,11 @@ 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;
@@ -55,118 +38,66 @@ interface TripTableProps {
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,
return ( onEdit, onDelete, onView, onAddFirst, onPageChange,
<div style={cardStyle}> }: TripTableProps) {
{/* column headers */} const columns: TableColumn<Trip>[] = [
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, ...thStyle }}> {
<span>الرحلة</span> key: "title",
<span>السائق</span> header: "الرحلة",
<span>السيارة</span> width: "2fr",
<span>الفرع</span> render: (t) => (
<span>الحالة</span>
<span style={{ textAlign: "center" }}>وقت البدء</span>
<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>
) : trips.length === 0 ? (
/* empty state */
<EmptyState
icon="🚚"
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد رحلات لعرضها."}
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> <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{t.title}</p> <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> <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{t.tripNumber}</p>
</div> </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> { key: "driver", header: "السائق", width: "1.3fr", render: (t) => t.driver?.name ?? "—" },
<StatusBadge status={t.status} /> {
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}> key: "car",
{fmtDate(t.startTime)} header: "السيارة",
</span> width: "1.3fr",
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}> render: (t) => (t.car ? `${t.car.manufacturer} ${t.car.model}` : "—"),
{/* view */} },
<IconBtn title={`عرض ${t.title}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(t)}> { key: "branch", header: "الفرع", width: "1fr", render: (t) => t.branch?.name ?? "—" },
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> { key: "status", header: "الحالة", width: "1fr", render: (t) => <StatusBadge status={t.status} /> },
<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" /> key: "startTime",
</svg> header: "معاد البداء",
</IconBtn> width: "1fr",
{/* edit */} align: "center",
<IconBtn title={`تعديل ${t.title}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(t)}> render: (t) => fmtDate(t.startTime),
<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 */} return (
{pages > 1 && ( <ReusableTable
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}> columns={columns}
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}> data={trips}
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> loading={loading}
</span> search={search}
<div style={{ display: "flex", gap: "0.5rem" }}> page={page}
<Button pages={pages}
type="button" onPageChange={onPageChange}
variant="secondary" emptyIcon="🚚"
size="sm" emptyDescription={!search ? "No trips to display." : undefined}
onClick={() => onPageChange(Math.max(1, page - 1))} emptyAction={
disabled={page === 1} !search && (
> <Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
السابق Add first trip
</Button> </Button>
<Button )
type="button" }
variant="secondary" renderActions={(t) => (
size="sm" <ActionButtons
onClick={() => onPageChange(Math.min(pages, page + 1))} itemLabel={t.title}
disabled={page === pages} onView={() => onView(t)}
> onEdit={() => onEdit(t)}
التالي onDelete={() => onDelete(t)}
</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,17 +1,5 @@
"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";

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,32 +1,13 @@
"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 {
@@ -39,97 +20,60 @@ interface ArchivedTripTableProps {
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>
<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)] 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> <div>
<TripStatusBadge status={trip.status} /> <p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{t.title}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-brand-600)" }}>{t.tripNumber}</p>
</div> </div>
<span className="text-center text-[12px] text-[var(--color-text-muted)]"> ),
{fmtDate(trip.startTime)} },
{
key: "status",
header: "الحالة",
width: "1.5fr",
render: (t) => {
const s = TRIP_STATUS_MAP[t.status];
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: s.color }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
{s.label}
</span> </span>
<span className="text-center text-[12px] text-[var(--color-text-muted)]"> );
{fmtDate(trip.endTime)} },
},
{ key: "start", header: "تاريخ البداية", width: "1fr", align: "center", render: (t) => fmtDate(t.startTime) },
{ key: "end", header: "تاريخ الانتهاء", width: "1fr", align: "center", render: (t) => fmtDate(t.endTime) },
{
key: "cash",
header: "المبلغ المحصل",
width: "1fr",
align: "center",
render: (t) => (
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)" }}>
{t.totalCashCollected != null ? `${Number(t.totalCashCollected).toLocaleString("en-US")} SAR` : "—"}
</span> </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 ── */} return (
{pages > 1 && ( <ReusableTable
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-6 py-3.5"> columns={columns}
<span className="text-[12px] text-[var(--color-text-muted)]"> data={trips}
صفحة <strong className="text-[var(--color-text-primary)]">{page}</strong> من{" "} loading={loading}
<strong className="text-[var(--color-text-primary)]">{pages}</strong> search={search}
</span> page={page}
<div className="flex gap-2"> pages={pages}
<Button onPageChange={onPageChange}
variant="secondary" onRowClick={onView}
size="sm" emptyIcon="🗄️"
disabled={page === 1} emptyDescription={search ? `No results for "${search}"` : "No archived trips."}
onClick={() => onPageChange(Math.max(1, page - 1))} />
>
السابق
</Button>
<Button
variant="secondary"
size="sm"
disabled={page === pages}
onClick={() => onPageChange(Math.min(pages, page + 1))}
>
التالي
</Button>
</div>
</div>
)}
</div>
); );
} }

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

@@ -26,3 +26,6 @@ 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

@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
import type { Notification } from "@/src/hooks/useUser";
interface ToastProps {
notification: Notification | null;
}
export function Toast({ notification }: ToastProps) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (notification) {
setVisible(true);
} else {
// dismiss after 250ms to allow exit animation
const t = setTimeout(() => setVisible(false), 300);
return () => clearTimeout(t);
}
}, [notification]);
if (!visible && !notification) return null;
const isSuccess = notification?.type === "success";
return (
<div
role="status" aria-live="polite" aria-atomic="true"
style={{
position: "fixed", bottom: 24, left: "50%",
transform: `translateX(-50%) translateY(${notification ? "0" : "16px"})`,
zIndex: 9999,
transition: "transform 250ms ease, opacity 250ms ease",
opacity: notification ? 1 : 0,
pointerEvents: "none",
}}
>
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.75rem 1.25rem",
borderRadius: "var(--radius-full)",
background: isSuccess ? "#065F46" : "#7F1D1D",
color: "#FFFFFF",
fontSize: 13, fontWeight: 600,
boxShadow: "0 8px 32px rgba(0,0,0,0.25)",
maxWidth: "90vw", whiteSpace: "nowrap",
fontFamily: "var(--font-sans)",
}}>
{/* icon */}
<span style={{ fontSize: 16 }}>{isSuccess ? "✓" : "⚠"}</span>
<span>{notification?.message}</span>
</div>
</div>
);
}

View File

@@ -1,33 +1,31 @@
"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;
@@ -41,118 +39,77 @@ interface UserTableProps {
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,
return ( onEdit, onDelete, onView, onAddFirst, onPageChange,
<div style={cardStyle}> }: UserTableProps) {
{/* column headers */} const columns: TableColumn<User>[] = [
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> {
<span>الاسم</span> key: "name",
<span>اسم المستخدم</span> header: "الاسم",
<span>الفرع</span> width: "2fr",
<span>الدور</span> render: (u) => (
<span>الحالة</span> <div>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> <p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p>
<span style={{ textAlign: "center" }}>إجراءات</span> <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
{u.phone}
</p>
</div> </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" }),
},
];
{/* loading state */} return (
{loading ? ( <ReusableTable
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> columns={columns}
<Spinner size="sm" className="text-blue-600" /> data={users}
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> loading={loading}
</div> search={search}
) : users.length === 0 ? ( page={page}
/* empty state */ pages={pages}
<EmptyState onPageChange={onPageChange}
icon="👥" emptyIcon="👥"
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."} emptyDescription={!search ? "No users to display." : undefined}
action={ emptyAction={
!search && ( !search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}> <Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
أضف أول مستخدم Add first user
</Button> </Button>
) )
} }
renderActions={(u) => (
<ActionButtons
itemLabel={u.name}
onView={() => onView(u)}
onEdit={() => onEdit(u)}
onDelete={() => onDelete(u)}
/> />
) : (
/* 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,33 +1,19 @@
"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;
@@ -38,97 +24,56 @@ interface ArchivedUserTableProps {
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) {
return ( const columns: TableColumn<ArchivedUser>[] = [
<div style={cardStyle}> {
{/* column headers */} key: "name",
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> header: "الاسم",
<span>الاسم</span> width: "2fr",
<span>اسم المستخدم</span> render: (u) => (
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>آخر تحديث</span>
<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>
) : 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> <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p> <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> <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
</div> </div>
),
},
{
key: "userName",
header: "الاسم المستخدم",
width: "1.5fr",
render: (u) => (
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span> <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" })} { key: "status", header: "الحالة", width: "1fr", render: (u) => <StatusBadge active={u.isActive} /> },
</span> {
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}> key: "createdAt",
{new Date(u.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} header: "تاريخ الانشاء",
</span> width: "1fr",
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}> align: "center",
{/* view only — delete/restore not implemented yet */} render: (u) => new Date(u.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
<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" /> key: "updatedAt",
<circle cx="12" cy="12" r="3" /> header: "تاريخ التعديل",
</svg> width: "1fr",
</IconBtn> align: "center",
</div> render: (u) => new Date(u.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
</li> }
))} ];
</ul>
)}
{/* pagination */} return (
{pages > 1 && ( <ReusableTable
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}> columns={columns}
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}> data={users}
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> loading={loading}
</span> search={search}
<div style={{ display: "flex", gap: "0.5rem" }}> page={page}
<Button pages={pages}
type="button" onPageChange={onPageChange}
variant="secondary" emptyIcon="🗄️"
size="sm" emptyDescription={search ? `No results for "${search}"` : "No archived users."}
onClick={() => onPageChange(Math.max(1, page - 1))} renderActions={(u) => <ActionButtons itemLabel={u.name} onView={() => onView(u)} />}
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

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

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

View File

@@ -1,20 +1,17 @@
"use client"; "use client";
import { Button, EmptyState, IconBtn, Spinner } from "../../UI"; // src/Components/Car/archive/ArchivedCarTable.tsx
import { STATUS_MAP, fmtDateShort } from "@/src/types/car"; // Converted to use ReusableTable, matching DriverTable / OrderTable /
import type { Car } from "@/src/types/car"; // AuditTable — previously this was its own hand-rolled cardStyle/thStyle
// grid. Same columns, same view-only action (delete/restore still not
// implemented), same empty/loading text. Row click now also opens the
// view action, same as Driver/Order's onRowClick, in addition to the
// explicit eye icon.
// ── card / header styles ───────────────────────────────────────────────────── import { IconBtn, ReusableTable } from "../../UI";
const cardStyle: React.CSSProperties = { import { STATUS_MAP, fmtDateShort } from "@/src/types/car";
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", import type { TableColumn } from "@/src/types/models";
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)", import type { Car } from "@/src/types/car";
};
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 ArchivedCarTableProps { interface ArchivedCarTableProps {
@@ -29,100 +26,89 @@ interface ArchivedCarTableProps {
// ── main table ─────────────────────────────────────────────────────────────── // ── main table ───────────────────────────────────────────────────────────────
export function ArchivedCarTable({ cars, loading, search, page, pages, onView, onPageChange }: ArchivedCarTableProps) { export function ArchivedCarTable({ cars, loading, search, page, pages, onView, onPageChange }: ArchivedCarTableProps) {
return ( const columns: TableColumn<Car>[] = [
<div style={cardStyle}> {
{/* column headers */} key: "vehicle",
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> header: "المركبة",
<span>المركبة</span> width: "2fr",
<span>رقم اللوحة</span> render: (c) => (
<span>الفرع</span>
<span>آخر حالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإضافة</span>
<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>
) : cars.length === 0 ? (
/* empty state */
<EmptyState
icon="🚗"
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد مركبات في الأرشيف."}
/>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{cars.map((c, i) => {
const status = STATUS_MAP[c.currentStatus];
return (
<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> <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}> <p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
{c.manufacturer} {c.model} {c.manufacturer} {c.model}
</p> </p>
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{c.year}{c.color ? ` · ${c.color}` : ""}</p> <p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>
{c.year}{c.color ? ` · ${c.color}` : ""}
</p>
</div> </div>
),
},
{
key: "plate",
header: "رقم اللوحة",
width: "1.5fr",
render: (c) => (
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}> <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
{c.plateLetters} {c.plateNumber} {c.plateLetters} {c.plateNumber}
</span> </span>
<span style={{ color: "var(--color-text-secondary)" }}>{c.branch?.name ?? "—"}</span> ),
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${status.border}`, background: status.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 700, color: status.color, width: "fit-content" }}> },
{ key: "branch", header: "الفرع", width: "1fr", render: (c) => c.branch?.name ?? "—" },
{
key: "status",
header: "آخر حالة",
width: "1fr",
render: (c) => {
const status = STATUS_MAP[c.currentStatus];
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
borderRadius: "var(--radius-full)",
border: `1px solid ${status.border}`,
background: status.bg,
padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 700, color: status.color,
width: "fit-content",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: status.dot }} /> <span style={{ width: 6, height: 6, borderRadius: "50%", background: status.dot }} />
{status.label} {status.label}
</span> </span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}> );
},
},
{
key: "createdAt",
header: "تاريخ الإضافة",
width: "1fr",
render: (c) => (
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
{fmtDateShort(c.createdAt)} {fmtDateShort(c.createdAt)}
</span> </span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}> ),
{/* view only — delete/restore not implemented yet */} },
<IconBtn title={`عرض ${c.manufacturer} ${c.model}`} 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 */} return (
{pages > 1 && ( <ReusableTable
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}> columns={columns}
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}> data={cars}
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> loading={loading}
</span> search={search}
<div style={{ display: "flex", gap: "0.5rem" }}> page={page}
{[ pages={pages}
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, onPageChange={onPageChange}
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, actionsWidth="100px"
].map(btn => ( onRowClick={onView}
<Button emptyDescription={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد مركبات في الأرشيف."}
key={btn.label} renderActions={(c) => (
type="button" // view only — delete/restore not implemented yet
variant="secondary" <IconBtn
size="sm" title={`عرض ${c.manufacturer} ${c.model}`}
onClick={btn.action} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0"
disabled={btn.disabled} onClick={() => onView(c)}
> >
{btn.label} <i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
</Button> </IconBtn>
))}
</div>
</div>
)} )}
</div> />
); );
} }

View File

@@ -0,0 +1,74 @@
"use client";
import { useEffect } from "react";
import { Spinner } from "../UI";
import { Role } from "@/src/types/role";
interface DeleteRoleModalProps {
role: Role;
deleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function DeleteRoleModal({ role, deleting, onCancel, onConfirm }: DeleteRoleModalProps) {
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-role-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 */}
<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-role-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)" }}>{role.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,9 @@
"use client"; "use client";
import { Role } from "@/src/types/role"; import { Button, ReusableTable, ActionButtons } from "../UI";
import { Button, EmptyState, IconBtn, Spinner } from "../UI"; import type { TableColumn } from "@/src/types/models";
import type { Role } from "@/src/types/role";
// ── 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={{ <span style={{
@@ -18,26 +16,11 @@ function StatusBadge({ active }: { active: boolean }) {
color: active ? "#166534" : "#991B1B", 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>
); );
} }
// ── 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 RoleTableProps { interface RoleTableProps {
roles: Role[]; roles: Role[];
loading: boolean; loading: boolean;
@@ -51,124 +34,70 @@ interface RoleTableProps {
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── Main table ────────────────────────────────────────────────────────────────
export function RoleTable({ export function RoleTable({
roles, loading, search, page, pages, roles, loading, search, page, pages,
onEdit, onDelete, onView, onAddFirst, onPageChange, onEdit, onDelete, onView, onAddFirst, onPageChange,
}: RoleTableProps) { }: RoleTableProps) {
return ( const columns: TableColumn<Role>[] = [
<div style={cardStyle}> {
{/* Column headers */} key: "name",
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 3fr 1fr 1fr 120px", ...thStyle }}> header: " الدور",
<span>اسم الدور</span> width: "2fr",
<span>الوصف</span> render: (r) => <span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>{r.name}</span>,
<span>الصلاحيات</span> },
<span>الحالة</span> {
<span style={{ textAlign: "center" }}>إجراءات</span> key: "description",
</div> header: "الوصف",
width: "3fr",
{/* Loading */} render: (r) =>
{loading ? ( r.description || <span style={{ color: "var(--color-text-hint)", fontStyle: "italic" }}>No description</span>,
<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> key: "permissions",
</div> header: "الصلحيات",
) : roles.length === 0 ? ( width: "1fr",
/* Empty state */ render: (r) => (
<EmptyState
icon="🛡️"
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار لعرضها"}
description={!search ? "ابدأ بإنشاء أول دور في النظام" : undefined}
action={
!search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
إضافة أول دور
</Button>
)
}
/>
) : (
/* Data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{roles.map((role, i) => (
<li key={role.id} style={{
display: "grid", gridTemplateColumns: "2fr 3fr 1fr 1fr 120px",
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,
}}>
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>{role.name}</span>
<span style={{ color: "var(--color-text-secondary)", fontSize: 12 }}>
{role.description || <span style={{ color: "var(--color-text-hint)", fontStyle: "italic" }}>لا يوجد وصف</span>}
</span>
<span style={{ <span style={{
display: "inline-flex", alignItems: "center", justifyContent: "center", display: "inline-flex", alignItems: "center", justifyContent: "center",
width: 28, height: 28, borderRadius: "var(--radius-full)", width: 28, height: 28, borderRadius: "var(--radius-full)",
background: "#EFF6FF", border: "1px solid #BFDBFE", background: "#EFF6FF", border: "1px solid #BFDBFE",
fontSize: 11, fontWeight: 700, color: "#1D4ED8", fontSize: 11, fontWeight: 700, color: "#1D4ED8",
}}> }}>
{role.permissions?.length ?? 0} {r.permissions?.length ?? 0}
</span> </span>
<StatusBadge active={role.isActive} /> ),
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}> },
{/* View */} { key: "status", header: "الحالة", width: "1fr", render: (r) => <StatusBadge active={r.isActive} /> },
<IconBtn title={`عرض ${role.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(role)}> ];
<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={`تعديل ${role.name}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(role)}>
<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={`حذف ${role.name}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(role)}>
<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 */} return (
{pages > 1 && ( <ReusableTable
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}> columns={columns}
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}> data={roles}
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> loading={loading}
</span> search={search}
<div style={{ display: "flex", gap: "0.5rem" }}> page={page}
<Button pages={pages}
type="button" onPageChange={onPageChange}
variant="secondary" actionsWidth="120px"
size="sm" emptyIcon="🛡️"
onClick={() => onPageChange(Math.max(1, page - 1))} emptyTitle={search ? undefined : "No roles to display"}
disabled={page === 1} emptyDescription={!search ? "Start by creating the first role in the system" : undefined}
> emptyAction={
السابق !search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
Add first role
</Button> </Button>
<Button )
type="button" }
variant="secondary" renderActions={(r) => (
size="sm" <ActionButtons
onClick={() => onPageChange(Math.min(pages, page + 1))} itemLabel={r.name}
disabled={page === pages} onView={() => onView(r)}
> onEdit={() => onEdit(r)}
التالي onDelete={() => onDelete(r)}
</Button> />
</div>
</div>
)} )}
</div> />
); );
} }

View File

@@ -59,10 +59,7 @@ function RoleIcon() {
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-shield-check" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
<path d="M12 2 3 7v6c0 5 4 9 9 9s9-4 9-9V7l-9-5z" />
<path d="m9 12 2 2 4-4" />
</svg>
</div> </div>
); );
} }

View File

@@ -1,33 +1,18 @@
"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 { ArchivedRole } from "@/src/types/role"; import type { ArchivedRole } from "@/src/types/role";
// ── 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 ArchivedRoleTableProps { interface ArchivedRoleTableProps {
roles: ArchivedRole[]; roles: ArchivedRole[];
loading: boolean; loading: boolean;
@@ -38,94 +23,39 @@ interface ArchivedRoleTableProps {
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ───────────────────────────────────────────────────────────────
export function ArchivedRoleTable({ roles, loading, search, page, pages, onView, onPageChange }: ArchivedRoleTableProps) { export function ArchivedRoleTable({ roles, loading, search, page, pages, onView, onPageChange }: ArchivedRoleTableProps) {
const columns: TableColumn<ArchivedRole>[] = [
{ key: "name", header: "الدور", width: "2fr", render: (r) => <span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>{r.name}</span> },
{ key: "description", header: "الوصف", width: "2fr", render: (r) => r.description || "—" },
{ key: "status", header: "الحالة", width: "1fr", render: (r) => <StatusBadge active={r.isActive} /> },
{
key: "createdAt",
header: "تاريخ الانشاء",
width: "1fr",
align: "center",
render: (r) => new Date(r.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
},
{
key: "updatedAt",
header: "تاريخ التعديل",
width: "1fr",
align: "center",
render: (r) => new Date(r.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 2fr 1fr 1fr 1fr 100px", ...thStyle }}> data={roles}
<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 roles."}
renderActions={(r) => <ActionButtons itemLabel={r.name} onView={() => onView(r)} />}
{/* 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>
) : roles.length === 0 ? (
/* empty state */
<EmptyState
icon="🛡️"
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار في الأرشيف."}
/> />
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{roles.map((r, i) => (
<li key={r.id} style={{
display: "grid", gridTemplateColumns: "2fr 2fr 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,
}}>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{r.name}</p>
<span style={{ color: "var(--color-text-secondary)" }}>{r.description || "—"}</span>
<StatusBadge active={r.isActive} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(r.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(r.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={`عرض ${r.name}`} 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>
)}
{/* 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 ArchivedRolesModalProps {
} }
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 ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) { export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) {

View File

@@ -0,0 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import { clientService } from "@/src/services/client.service";
import type { Order } from "@/src/types/order";
interface State {
orders: Order[];
error: string | null;
loading: boolean;
}
/**
* Fetches all orders for a client session.
*
* @example
* const { orders, loading, error } = useClientOrders(session.id);
*/
export function useClientOrders(clientId: string): State {
const [state, setState] = useState<State>({
orders: [], error: null, loading: true,
});
useEffect(() => {
if (!clientId) {
setState({ orders: [], error: null, loading: false });
return;
}
let cancelled = false;
clientService
.getOrders(clientId)
.then(orders => {
if (!cancelled) setState({ orders, error: null, loading: false });
})
.catch((err: Error) => {
if (!cancelled) setState({ orders: [], error: err.message, loading: false });
});
return () => { cancelled = true; };
}, [clientId]);
return state;
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useEffect, useState } from "react";
import { dashboardService } from "@/src/services/dashboard.service";
import { getStoredToken } from "@/src/lib/auth";
import type { DashboardOverview } from "@/src/types/dashboard";
interface State {
data: DashboardOverview | null;
error: string | null;
loading: boolean;
}
/**
* Fetches the redesigned dashboard home overview (KPIs, alerts, trends,
* recent activity). Kept as its own hook alongside useDashboardSummary.ts
* rather than replacing it — same "additive, not destructive" approach
* used elsewhere when a new backend route isn't live yet.
*/
export function useDashboardOverview(): State {
const [state, setState] = useState<State>({ data: null, error: null, loading: true });
useEffect(() => {
let cancelled = false;
const token = getStoredToken(); // always null now — kept for the service signature
dashboardService
.getOverview(token)
.then((data) => {
if (!cancelled) setState({ data, error: null, loading: false });
})
.catch((err: Error) => {
if (!cancelled) setState({ data: null, error: err.message, loading: false });
});
return () => { cancelled = true; };
}, []);
return state;
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useReducer, useState } from "react"; import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { driverService } from "@/src/services/driver.service"; import { driverService } from "@/src/services/driver.service";
import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver"; import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver";
@@ -59,11 +59,21 @@ export function useDrivers() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [notification, setNotification] = useState<ToastNotification | null>(null); const [notification, setNotification] = useState<ToastNotification | null>(null);
// Ref so the dismiss timer can be cleared if a new notification arrives
// before the previous one expires — prevents stale-closure memory leaks
// and premature/duplicate toast dismissal on rapid successive calls.
// Pattern copied from useTrip.ts for consistency across list hooks.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const notify = useCallback((n: ToastNotification) => { const notify = useCallback((n: ToastNotification) => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(n); setNotification(n);
setTimeout(() => setNotification(null), 4000); timerRef.current = setTimeout(() => setNotification(null), 4000);
}, []); }, []);
// Clear pending timer on unmount
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
// ── Fetch drivers ───────────────────────────────────────────────────────── // ── Fetch drivers ─────────────────────────────────────────────────────────
const loadDrivers = useCallback(async (p: number, q: string) => { const loadDrivers = useCallback(async (p: number, q: string) => {
dispatch({ type: "LOAD_START" }); dispatch({ type: "LOAD_START" });
@@ -179,7 +189,6 @@ export function useDrivers() {
updateDriver, updateDriver,
deleteDriver, deleteDriver,
notification, notification,
clearNotification: () => setNotification(null),
reload: () => loadDrivers(page, search), reload: () => loadDrivers(page, search),
}; };
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useReducer, useState } from "react"; import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { orderService } from "@/src/services/order.service"; import { orderService } from "@/src/services/order.service";
import type { import type {
Order, Order,
@@ -64,11 +64,21 @@ export function useOrders() {
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [notification, setNotification] = useState<ToastNotification | null>(null); const [notification, setNotification] = useState<ToastNotification | null>(null);
// Ref so the dismiss timer can be cleared if a new notification arrives
// before the previous one expires — prevents stale-closure memory leaks
// and premature/duplicate toast dismissal on rapid successive calls.
// Pattern copied from useTrip.ts for consistency across list hooks.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const notify = useCallback((n: ToastNotification) => { const notify = useCallback((n: ToastNotification) => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(n); setNotification(n);
setTimeout(() => setNotification(null), 4000); timerRef.current = setTimeout(() => setNotification(null), 4000);
}, []); }, []);
// Clear pending timer on unmount
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
// ── Fetch orders ─────────────────────────────────────────────────────── // ── Fetch orders ───────────────────────────────────────────────────────
const loadOrders = useCallback(async (p: number, q: string, status: string) => { const loadOrders = useCallback(async (p: number, q: string, status: string) => {
dispatch({ type: "LOAD_START" }); dispatch({ type: "LOAD_START" });

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useReducer, useState } from "react"; import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { userService } from "@/src/services/user.service"; import { userService } from "@/src/services/user.service";
import type { Branch } from "@/src/types/branch"; import type { Branch } from "@/src/types/branch";
@@ -42,12 +42,22 @@ export function useUsers() {
const [branches, setBranches] = useState<Branch[]>([]); const [branches, setBranches] = useState<Branch[]>([]);
const [notification, setNotification] = useState<ToastNotification | null>(null); const [notification, setNotification] = useState<ToastNotification | null>(null);
// Ref so the dismiss timer can be cleared if a new notification arrives
// before the previous one expires — prevents stale-closure memory leaks
// and premature/duplicate toast dismissal on rapid successive calls.
// Pattern copied from useTrip.ts for consistency across list hooks.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// notify with auto-dismiss after 4s // notify with auto-dismiss after 4s
const notify = useCallback((n: ToastNotification) => { const notify = useCallback((n: ToastNotification) => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(n); setNotification(n);
setTimeout(() => setNotification(null), 4000); timerRef.current = setTimeout(() => setNotification(null), 4000);
}, []); }, []);
// Clear pending timer on unmount
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
// fetch users with pagination and search // fetch users with pagination and search
const loadUsers = useCallback(async (p: number, q: string) => { const loadUsers = useCallback(async (p: number, q: string) => {
dispatch({ type: "LOAD_START" }); dispatch({ type: "LOAD_START" });
@@ -90,7 +100,7 @@ useEffect(() => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
const res = await userService.update(id, data, token); const res = await userService.update(id, data, token);
dispatch({ type: "UPDATE", user: res }); dispatch({ type: "UPDATE", user: res?.data });
notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." }); notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." });
return true; return true;
} catch (err) { } catch (err) {

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