create user pages crud also update pages ui build order and car ,driver first page interfase create model layer to componants

This commit is contained in:
m7amedez5511
2026-06-10 16:58:17 +03:00
parent 53c03e9867
commit e0e38dc87a
34 changed files with 2963 additions and 862 deletions

View File

@@ -1,89 +1,27 @@
"use client"; "use client";
import React, { useState, type FC, type FormEvent } from "react"; import React, { useState, type FC, type FormEvent } from "react";
import { saveSession, type ClientSession } from "@/utils/helperFun"; import { saveSession, type ClientSession } from "@/lib/session";
import { Spinner } from "../UI"; import { clientService } from "@/services/client.service";
import { Spinner } from "../../Components/UI";
import { Alert } from "../../Components/UI";
import { Button } from "../../Components/UI";
import { Input } from "../../Components/UI";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
console.log("API_BASE:", API_BASE);
//make sure API_BASE ends without slash
// ─── Types ─────────────────────────────────── // ─── Types ───────────────────────────────────
interface RegForm { interface RegForm {
name: string; name: string;
email: string; email: string;
phone: string; phone: string;
companyName: string; companyName: string;
} }
interface RegErrors { interface RegErrors {
name?: string; name?: string;
email?: string; email?: string;
phone?: string; phone?: string;
} }
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
icon?: React.ReactNode;
}
const Input: FC<InputProps> = ({ label, error, icon, className = "", ...rest }) => (
<div className="flex flex-col gap-1">
<label className="text-[12px] font-semibold text-[#374151]">{label}</label>
<div className="relative">
{icon && <div className="absolute right-3 top-1/2 -translate-y-1/2 text-[#9CA3AF]">{icon}</div>}
<input
className={`w-full h-10 ${icon ? "pr-9" : "pr-3"} pl-3 border rounded-lg text-[13px] text-[#111827]
placeholder-[#9CA3AF] outline-none transition focus:border-[#1A73E8] focus:ring-2
focus:ring-[#1A73E8]/15 text-right
${error ? "border-red-400 bg-red-50" : "border-[#E5E7EB] bg-white"} ${className}`}
dir="rtl"
{...rest}
/>
</div>
{error && <p className="text-[11px] text-red-500 font-medium text-right">{error}</p>}
</div>
);
interface BtnProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
loading?: boolean;
}
const PrimaryBtn: FC<BtnProps> = ({ loading, children, className = "", disabled, ...rest }) => (
<button
disabled={loading || disabled}
className={`flex items-center justify-center gap-2 h-11 px-6 bg-[#1A73E8] hover:bg-[#1557B0]
active:scale-[.98] text-white font-bold text-[13px] rounded-lg transition-all duration-150
disabled:opacity-60 disabled:cursor-not-allowed ${className}`}
{...rest}
>
{loading && <Spinner />}
{children}
</button>
);
const Alert: FC<{ type?: "error" | "info" | "success"; message: string; onClose?: () => void }> = ({
type = "error", message, onClose,
}) => {
const styles = {
error: "bg-red-50 border-red-200 text-red-700",
info: "bg-blue-50 border-blue-200 text-blue-700",
success: "bg-emerald-50 border-emerald-200 text-emerald-700",
};
const icons = { error: "⚠", info: "", success: "✓" };
return (
<div className={`flex items-start gap-2 border rounded-lg px-3 py-2.5 text-[12px] font-medium ${styles[type]}`} dir="rtl">
<span className="flex-shrink-0">{icons[type]}</span>
<span className="flex-1">{message}</span>
{onClose && (
<button onClick={onClose} className="flex-shrink-0 opacity-60 hover:opacity-100 text-[14px] leading-none">×</button>
)}
</div>
);
};
// ─── RegisterStep ──────────────────────────── // ─── RegisterStep ────────────────────────────
interface RegisterStepProps { interface RegisterStepProps {
onSuccess: (s: ClientSession) => void; onSuccess: (s: ClientSession) => void;
@@ -112,23 +50,17 @@ const RegisterStep: FC<RegisterStepProps> = ({ onSuccess }) => {
if (!validate()) return; if (!validate()) return;
setLoading(true); setLoading(true);
setApiError(""); setApiError("");
try {
const res = await fetch(`${API_BASE}/client`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
companyName: form.companyName.trim() || undefined,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? "فشل التسجيل");
// save session and proceed try {
const data = await clientService.create({
name: form.name.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
companyName: form.companyName.trim() || undefined,
});
const session: ClientSession = { const session: ClientSession = {
id: data.id ?? data._id, id: data.id,
name: data.name, name: data.name,
email: data.email, email: data.email,
phone: data.phone, phone: data.phone,
@@ -151,7 +83,7 @@ const RegisterStep: FC<RegisterStepProps> = ({ onSuccess }) => {
<div dir="rtl"> <div dir="rtl">
<div className="mb-6"> <div className="mb-6">
<div className="inline-flex items-center gap-1.5 bg-[#EBF3FF] border border-[#BFDBFE] rounded-full px-3 py-1 text-[11px] font-bold text-[#1E3A8A] mb-3"> <div className="inline-flex items-center gap-1.5 bg-[#EBF3FF] border border-[#BFDBFE] rounded-full px-3 py-1 text-[11px] font-bold text-[#1E3A8A] mb-3">
<span className="w-1.5 h-1.5 rounded-full bg-[#1A73E8]" /> <span aria-hidden="true" className="w-1.5 h-1.5 rounded-full bg-[#1A73E8]" />
عميل جديد عميل جديد
</div> </div>
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight leading-tight">إنشاء حساب جديد</h2> <h2 className="text-[22px] font-bold text-[#111827] tracking-tight leading-tight">إنشاء حساب جديد</h2>
@@ -171,7 +103,7 @@ const RegisterStep: FC<RegisterStepProps> = ({ onSuccess }) => {
value={form.name} value={form.name}
onChange={set("name")} onChange={set("name")}
error={errors.name} error={errors.name}
icon={<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>} autoComplete="name"
/> />
<Input <Input
label="البريد الإلكتروني *" label="البريد الإلكتروني *"
@@ -180,7 +112,7 @@ const RegisterStep: FC<RegisterStepProps> = ({ onSuccess }) => {
value={form.email} value={form.email}
onChange={set("email")} onChange={set("email")}
error={errors.email} error={errors.email}
icon={<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>} autoComplete="email"
/> />
<Input <Input
label="رقم الجوال *" label="رقم الجوال *"
@@ -189,21 +121,20 @@ const RegisterStep: FC<RegisterStepProps> = ({ onSuccess }) => {
value={form.phone} value={form.phone}
onChange={set("phone")} onChange={set("phone")}
error={errors.phone} error={errors.phone}
icon={<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 10.5 19.79 19.79 0 0 1 1.64 1.9a2 2 0 0 1 1.99-2.18h3a2 2 0 0 1 2 1.72c.127.96.36 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 7.91a16 16 0 0 0 6.18 6.18l.96-.96a2 2 0 0 1 2.11-.45c.907.34 1.85.573 2.81.7A2 2 0 0 1 21.9 16.9l.02.02z"/></svg>} autoComplete="tel"
/> />
<Input <Input
label="اسم الشركة (اختياري)" label="اسم الشركة (اختياري)"
placeholder="شركة لوجي فلو للتوصيل" placeholder="شركة لوجي فلو للتوصيل"
value={form.companyName} value={form.companyName}
onChange={set("companyName")} onChange={set("companyName")}
icon={<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="7" width="20" height="15" rx="1"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>} autoComplete="organization"
/> />
<div className="pt-2"> <div className="pt-2">
<PrimaryBtn type="submit" loading={loading} className="w-full"> <Button type="submit" loading={loading} fullWidth>
إنشاء الحساب إنشاء الحساب
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="15 18 9 12 15 6"/></svg> </Button>
</PrimaryBtn>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,111 +1,40 @@
"use client"; "use client";
import React, { useState, useEffect, type FC } from "react"; import React, { useState, useEffect, type FC } from "react";
import { type ClientSession } from "@/utils/helperFun"; import { type ClientSession } from "@/lib/session";
import { Spinner } from "../UI"; import { clientService } from "@/services/client.service";
import type { ClientAddress } from "@/types/client";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!; import { Spinner } from "../../Components/UI";
import { Alert } from "../../Components/UI";
// ─── Types ─────────────────────────────────── import { Button } from "../../Components/UI";
export interface ClientAddress { import { Input } from "../../Components/UI";
_id: string;
label: string;
branchName: string | null;
contactPerson: { name: string; phone: string };
details: {
city: string;
district?: string;
street: string;
buildingNo?: string;
unitNo?: string;
zipCode?: string;
};
}
interface AddrForm { interface AddrForm {
label: string; label: string;
branchName: string; branchName: string;
contactName: string; contactName: string;
contactPhone: string; contactPhone: string;
city: string; city: string;
district: string; district: string;
street: string; street: string;
buildingNo: string; buildingNo: string;
unitNo: string; unitNo: string;
zipCode: string; zipCode: string;
} }
interface AddrErrors { interface AddrErrors {
city?: string; city?: string;
street?: string; street?: string;
zipCode?: string; zipCode?: string;
contactPhone?: string; contactPhone?: string;
} }
const LABEL_OPTIONS = ["عام", "المنزل", "المكتب الرئيسي", "المستودع", "الفرع"];
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
const Input: FC<InputProps> = ({ label, error, className = "", ...rest }) => (
<div className="flex flex-col gap-1">
<label className="text-[12px] font-semibold text-[#374151]">{label}</label>
<input
className={`w-full h-10 px-3 border rounded-lg text-[13px] text-[#111827]
placeholder-[#9CA3AF] outline-none transition focus:border-[#1A73E8]
focus:ring-2 focus:ring-[#1A73E8]/15 text-right
${error ? "border-red-400 bg-red-50" : "border-[#E5E7EB] bg-white"} ${className}`}
dir="rtl"
{...rest}
/>
{error && <p className="text-[11px] text-red-500 font-medium text-right">{error}</p>}
</div>
);
interface BtnProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
loading?: boolean;
}
const PrimaryBtn: FC<BtnProps> = ({ loading, children, className = "", disabled, ...rest }) => (
<button
disabled={loading || disabled}
className={`flex items-center justify-center gap-2 h-11 px-6 bg-[#1A73E8] hover:bg-[#1557B0]
active:scale-[.98] text-white font-bold text-[13px] rounded-lg transition-all duration-150
disabled:opacity-60 disabled:cursor-not-allowed ${className}`}
{...rest}
>
{loading && <Spinner />}
{children}
</button>
);
const Alert: FC<{ type?: "error" | "info" | "success"; message: string; onClose?: () => void }> = ({
type = "error", message, onClose,
}) => {
const styles = {
error: "bg-red-50 border-red-200 text-red-700",
info: "bg-blue-50 border-blue-200 text-blue-700",
success: "bg-emerald-50 border-emerald-200 text-emerald-700",
};
const icons = { error: "⚠", info: "", success: "✓" };
return (
<div className={`flex items-start gap-2 border rounded-lg px-3 py-2.5 text-[12px] font-medium ${styles[type]}`} dir="rtl">
<span className="flex-shrink-0">{icons[type]}</span>
<span className="flex-1">{message}</span>
{onClose && (
<button onClick={onClose} className="flex-shrink-0 opacity-60 hover:opacity-100 text-[14px] leading-none">×</button>
)}
</div>
);
};
// ─── AddressStep ─────────────────────────────
interface AddressStepProps { interface AddressStepProps {
session: ClientSession; session: ClientSession;
onSuccess: () => void; onSuccess: () => void;
} }
const LABEL_OPTIONS = ["عام", "المنزل", "المكتب الرئيسي", "المستودع", "الفرع"];
const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => { const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
const [savedAddresses, setSavedAddresses] = useState<ClientAddress[]>([]); const [savedAddresses, setSavedAddresses] = useState<ClientAddress[]>([]);
const [loadingAddrs, setLoadingAddrs] = useState(true); const [loadingAddrs, setLoadingAddrs] = useState(true);
@@ -119,26 +48,14 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
city: "", district: "", street: "", buildingNo: "", unitNo: "", zipCode: "", city: "", district: "", street: "", buildingNo: "", unitNo: "", zipCode: "",
}); });
// get saved addresses on mount
useEffect(() => { useEffect(() => {
(async () => { clientService.getAddresses(session.id)
try { .then(list => {
const res = await fetch(`${API_BASE}/client/${session.id}/addresses`); setSavedAddresses(list);
const data = await res.json(); if (list.length > 0) setSelectedId(list[0]._id);
if (res.ok) { })
// api might return either { addresses: [...] } or just [...] .catch(() => setShowNew(true))
const list: ClientAddress[] = Array.isArray(data) ? data : (data.addresses ?? []); .finally(() => setLoadingAddrs(false));
setSavedAddresses(list);
// auto-select first address if available
if (list.length > 0) setSelectedId(list[0]._id);
}
} catch {
// ignore fetch errors, user can add address manually
setShowNew(true);
} finally {
setLoadingAddrs(false);
}
})();
}, [session.id]); }, [session.id]);
const setF = const setF =
@@ -165,7 +82,6 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
const handleNext = async () => { const handleNext = async () => {
if (selectedId) { if (selectedId) {
// existing address selected → proceed without API call
setLoading(true); setLoading(true);
await new Promise(r => setTimeout(r, 400)); await new Promise(r => setTimeout(r, 400));
setLoading(false); setLoading(false);
@@ -176,28 +92,19 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
setLoading(true); setLoading(true);
setApiError(""); setApiError("");
try { try {
const res = await fetch(`${API_BASE}/client/${session.id}/addresses`, { await clientService.addAddress(session.id, {
method: "POST", label: form.label,
headers: { "Content-Type": "application/json" }, branchName: form.branchName || null,
body: JSON.stringify({ contactPerson: { name: form.contactName, phone: form.contactPhone },
label: form.label, details: {
branchName: form.branchName || null, city: form.city.trim(),
contactPerson: { district: form.district.trim() || undefined,
name: form.contactName, street: form.street.trim(),
phone: form.contactPhone, buildingNo: form.buildingNo.trim() || undefined,
}, unitNo: form.unitNo.trim() || undefined,
details: { zipCode: form.zipCode.trim() || undefined,
city: form.city.trim(), },
district: form.district.trim() || undefined,
street: form.street.trim(),
buildingNo: form.buildingNo.trim() || undefined,
unitNo: form.unitNo.trim() || undefined,
zipCode: form.zipCode.trim() || undefined,
},
}),
}); });
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? "تعذّر حفظ العنوان");
onSuccess(); onSuccess();
} catch (err: unknown) { } catch (err: unknown) {
setApiError(err instanceof Error ? err.message : "تعذّر حفظ العنوان، يرجى المحاولة مجددًا."); setApiError(err instanceof Error ? err.message : "تعذّر حفظ العنوان، يرجى المحاولة مجددًا.");
@@ -210,7 +117,9 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
<div dir="rtl"> <div dir="rtl">
<div className="mb-6"> <div className="mb-6">
<div className="inline-flex items-center gap-1.5 bg-[#D1FAE5] border border-[#A7F3D0] rounded-full px-3 py-1 text-[11px] font-bold text-[#065F46] mb-3"> <div className="inline-flex items-center gap-1.5 bg-[#D1FAE5] border border-[#A7F3D0] rounded-full px-3 py-1 text-[11px] font-bold text-[#065F46] mb-3">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"><polyline points="20 6 9 17 4 12"/></svg> <svg aria-hidden="true" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
تم إنشاء الحساب تم إنشاء الحساب
</div> </div>
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight leading-tight">اختر عنوان التوصيل</h2> <h2 className="text-[22px] font-bold text-[#111827] tracking-tight leading-tight">اختر عنوان التوصيل</h2>
@@ -225,23 +134,24 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
</div> </div>
)} )}
{/* loading state */}
{loadingAddrs ? ( {loadingAddrs ? (
<div className="flex items-center justify-center py-8 gap-2"> <div className="flex items-center justify-center py-8 gap-2">
<Spinner /> <Spinner size="sm" />
<span className="text-[13px] text-[#6B7280]">جارٍ تحميل العناوين</span> <span className="text-[13px] text-[#6B7280]">جارٍ تحميل العناوين</span>
</div> </div>
) : ( ) : (
<> <>
{/* saved addresses */} {/* Saved addresses */}
{savedAddresses.length > 0 && ( {savedAddresses.length > 0 && (
<div className="mb-5"> <div className="mb-5">
<p className="text-[11px] font-bold text-[#9CA3AF] uppercase tracking-wide mb-2">العناوين المحفوظة</p> <p className="text-[11px] font-bold text-[#9CA3AF] uppercase tracking-wide mb-2">العناوين المحفوظة</p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2" role="radiogroup" aria-label="العناوين المحفوظة">
{savedAddresses.map(addr => ( {savedAddresses.map(addr => (
<button <button
key={addr._id} key={addr._id}
type="button" type="button"
role="radio"
aria-checked={selectedId === addr._id}
onClick={() => { setSelectedId(addr._id); setShowNew(false); }} onClick={() => { setSelectedId(addr._id); setShowNew(false); }}
className={`w-full text-right border rounded-xl p-3.5 transition-all duration-150 ${ className={`w-full text-right border rounded-xl p-3.5 transition-all duration-150 ${
selectedId === addr._id selectedId === addr._id
@@ -250,11 +160,16 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
}`} }`}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className={`w-5 h-5 rounded-full border-2 flex-shrink-0 mt-0.5 flex items-center justify-center transition-all ${ <div
selectedId === addr._id ? "border-[#1A73E8] bg-[#1A73E8]" : "border-[#D1D5DB]" aria-hidden="true"
}`}> className={`w-5 h-5 rounded-full border-2 flex-shrink-0 mt-0.5 flex items-center justify-center transition-all ${
selectedId === addr._id ? "border-[#1A73E8] bg-[#1A73E8]" : "border-[#D1D5DB]"
}`}
>
{selectedId === addr._id && ( {selectedId === addr._id && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3.5"><polyline points="20 6 9 17 4 12"/></svg> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
)} )}
</div> </div>
<div className="flex-1"> <div className="flex-1">
@@ -266,9 +181,7 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
}`}> }`}>
{addr.label} {addr.label}
</span> </span>
{addr.branchName && ( {addr.branchName && <span className="text-[12px] text-[#6B7280]">{addr.branchName}</span>}
<span className="text-[12px] text-[#6B7280]">{addr.branchName}</span>
)}
</div> </div>
<p className="text-[13px] font-semibold text-[#111827]"> <p className="text-[13px] font-semibold text-[#111827]">
{addr.details.street} {addr.details.street}
@@ -292,38 +205,37 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
</div> </div>
)} )}
{/* new address button */} {/* New address toggle */}
<button <button
type="button" type="button"
onClick={() => { setShowNew(v => !v); setSelectedId(null); }} onClick={() => { setShowNew(v => !v); setSelectedId(null); }}
className={`w-full border-2 border-dashed rounded-xl p-3 flex items-center justify-center gap-2 aria-expanded={showNew}
text-[13px] font-semibold transition-all ${ className={`w-full border-2 border-dashed rounded-xl p-3 flex items-center justify-center gap-2 text-[13px] font-semibold transition-all ${
showNew showNew
? "border-[#1A73E8] text-[#1A73E8] bg-[#EBF3FF]" ? "border-[#1A73E8] text-[#1A73E8] bg-[#EBF3FF]"
: "border-[#E5E7EB] text-[#6B7280] hover:border-[#1A73E8]/50 hover:text-[#1A73E8]" : "border-[#E5E7EB] text-[#6B7280] hover:border-[#1A73E8]/50 hover:text-[#1A73E8]"
}`} }`}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19"/> <line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/> <line x1="5" y1="12" x2="19" y2="12"/>
</svg> </svg>
إضافة عنوان جديد إضافة عنوان جديد
</button> </button>
{/* new address form */} {/* New address form */}
{showNew && ( {showNew && (
<div className="mt-4 border border-[#E5E7EB] rounded-xl p-4 bg-[#FAFAFA] flex flex-col gap-3"> <div className="mt-4 border border-[#E5E7EB] rounded-xl p-4 bg-[#FAFAFA] flex flex-col gap-3">
<p className="text-[11px] font-bold text-[#9CA3AF] uppercase tracking-wide">تفاصيل العنوان الجديد</p> <p className="text-[11px] font-bold text-[#9CA3AF] uppercase tracking-wide">تفاصيل العنوان الجديد</p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[12px] font-semibold text-[#374151]">التصنيف</label> <label htmlFor="addr-label" className="text-[12px] font-semibold text-[#374151]">التصنيف</label>
<select <select
id="addr-label"
value={form.label} value={form.label}
onChange={setF("label")} onChange={setF("label")}
className="h-10 px-3 border border-[#E5E7EB] rounded-lg text-[13px] text-[#111827] className="h-10 px-3 border border-[#E5E7EB] rounded-[var(--radius-md)] text-[13px] bg-white outline-none focus:border-[#1A73E8] focus:ring-2 focus:ring-[#1A73E8]/15 text-right"
bg-white outline-none focus:border-[#1A73E8] focus:ring-2
focus:ring-[#1A73E8]/15 text-right"
dir="rtl" dir="rtl"
> >
{LABEL_OPTIONS.map(l => <option key={l}>{l}</option>)} {LABEL_OPTIONS.map(l => <option key={l}>{l}</option>)}
@@ -337,13 +249,13 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
<Input label="رقم جوال جهة الاتصال" placeholder="+966 50 123 4567" value={form.contactPhone} onChange={setF("contactPhone")} error={formErrors.contactPhone} /> <Input label="رقم جوال جهة الاتصال" placeholder="+966 50 123 4567" value={form.contactPhone} onChange={setF("contactPhone")} error={formErrors.contactPhone} />
</div> </div>
<div className="h-px bg-[#E5E7EB]" /> <hr className="border-[#E5E7EB]" />
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Input label="المدينة *" placeholder="الرياض" value={form.city} onChange={setF("city")} error={formErrors.city} /> <Input label="المدينة *" placeholder="الرياض" value={form.city} onChange={setF("city")} error={formErrors.city} />
<Input label="الحي" placeholder="العليا" value={form.district} onChange={setF("district")} /> <Input label="الحي" placeholder="العليا" value={form.district} onChange={setF("district")} />
</div> </div>
<Input label="الشارع *" placeholder="طريق الملك فهد" value={form.street} onChange={setF("street")} error={formErrors.street} /> <Input label="الشارع *" placeholder="طريق الملك فهد" value={form.street} onChange={setF("street")} error={formErrors.street} />
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<Input label="رقم المبنى" placeholder="12" value={form.buildingNo} onChange={setF("buildingNo")} /> <Input label="رقم المبنى" placeholder="12" value={form.buildingNo} onChange={setF("buildingNo")} />
<Input label="رقم الوحدة" placeholder="3ب" value={form.unitNo} onChange={setF("unitNo")} /> <Input label="رقم الوحدة" placeholder="3ب" value={form.unitNo} onChange={setF("unitNo")} />
@@ -355,19 +267,20 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
)} )}
{!canProceed && !loadingAddrs && ( {!canProceed && !loadingAddrs && (
<p className="text-[12px] text-[#F59E0B] font-medium mt-3 flex items-center gap-1.5"> <p role="alert" className="text-[12px] text-[#F59E0B] font-medium mt-3 flex items-center gap-1.5">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
يرجى اختيار عنوان أو إضافة عنوان جديد للمتابعة. يرجى اختيار عنوان أو إضافة عنوان جديد للمتابعة.
</p> </p>
)} )}
<div className="mt-6"> <div className="mt-6">
<PrimaryBtn onClick={handleNext} loading={loading} disabled={!canProceed || loadingAddrs} className="w-full"> <Button
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="15 18 9 12 15 6"/></svg> onClick={handleNext}
loading={loading}
disabled={!canProceed || loadingAddrs}
fullWidth
>
التالي عرض الطلبات التالي عرض الطلبات
</PrimaryBtn> </Button>
</div> </div>
</div> </div>
); );

View File

@@ -1,74 +1,59 @@
"use client"; "use client";
import React, { type FC } from "react";
import { useClientOrders } from "@/hooks/useClientOrders";
import { fmtDate, fmtAmount } from "@/lib/formatters";
import { statusColor, statusLabel } from "@/lib/order-status";
import { clearSession, type ClientSession } from "@/lib/session";
import type { OrderStatus } from "@/types/client";
import { Spinner } from "../../Components/UI";
import React, { useState, useEffect, type FC } from "react"; // ─── Order status tracker ─────────────────────
import {
fmtDate,
fmtAmount,
statusColor,
statusLabel,
clearSession,
type ClientSession,
type OrderStatus,
} from "@/utils/helperFun";
import { Spinner } from "../UI";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
// ─── Types ───────────────────────────────────
export interface Order {
id: string;
createdAt: string;
status: OrderStatus;
totalAmount: number;
description: string;
}
// ─── Order Tracker ───────────────────────────
const STATUS_STEPS: OrderStatus[] = ["CREATED", "IN_TRANSIT", "DELIVERED"]; const STATUS_STEPS: OrderStatus[] = ["CREATED", "IN_TRANSIT", "DELIVERED"];
const STEP_LABELS = ["تم الإنشاء", "قيد التوصيل", "تم التسليم"]; const STEP_LABELS = ["تم الإنشاء", "قيد التوصيل", "تم التسليم"];
const STEP_ICONS = [
<svg key="c" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> const StepIcon: FC<{ step: number }> = ({ step }) => {
<rect x="9" y="3" width="6" height="4" rx="1"/> if (step === 0) return (
<path d="M4 7h16v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z"/> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
</svg>, <rect x="9" y="3" width="6" height="4" rx="1"/>
<svg key="t" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M4 7h16v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z"/>
<rect x="2" y="7" width="20" height="14" rx="2"/> </svg>
<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"/> if (step === 1) return (
<line x1="10" y1="14" x2="14" y2="14"/> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
</svg>, <rect x="2" y="7" width="20" height="14" rx="2"/>
<svg key="d" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
<polyline points="20 6 9 17 4 12"/> <line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/>
</svg>, </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 OrderTracker: FC<{ status: OrderStatus }> = ({ status }) => {
const idx = STATUS_STEPS.indexOf(status === "CANCELLED" ? "CREATED" : status); const idx = STATUS_STEPS.indexOf(status === "CANCELLED" ? "CREATED" : status);
return ( return (
<div className="flex items-center gap-0 py-2" dir="rtl"> <div className="flex items-center gap-0 py-2" dir="rtl" role="list" aria-label="تقدم الطلب">
{STATUS_STEPS.map((s, i) => ( {STATUS_STEPS.map((s, i) => (
<React.Fragment key={s}> <React.Fragment key={s}>
<div className="flex flex-col items-center gap-1"> <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 ${ <div className={`w-9 h-9 rounded-full flex items-center justify-center transition-all ${
status === "CANCELLED" status === "CANCELLED"
? "bg-[#FEE2E2] text-red-500 border-2 border-red-200" ? "bg-[#FEE2E2] text-red-500 border-2 border-red-200"
: i <= idx : i <= idx ? "bg-[#1A73E8] text-white" : "bg-white border-2 border-[#E5E7EB] text-[#D1D5DB]"
? "bg-[#1A73E8] text-white" }`} aria-current={i === idx ? "step" : undefined}>
: "bg-white border-2 border-[#E5E7EB] text-[#D1D5DB]" <StepIcon step={i} />
}`}>
{STEP_ICONS[i]}
</div> </div>
<span className={`text-[10px] font-semibold whitespace-nowrap ${ <span className={`text-[10px] font-semibold whitespace-nowrap ${
status === "CANCELLED" status === "CANCELLED" ? "text-red-400" : i <= idx ? "text-[#1A73E8]" : "text-[#9CA3AF]"
? "text-red-400"
: i <= idx ? "text-[#1A73E8]" : "text-[#9CA3AF]"
}`}> }`}>
{STEP_LABELS[i]} {STEP_LABELS[i]}
</span> </span>
</div> </div>
{i < STATUS_STEPS.length - 1 && ( {i < STATUS_STEPS.length - 1 && (
<div className={`flex-1 h-0.5 mb-4 mx-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]" status === "CANCELLED" ? "bg-red-200" : i < idx ? "bg-[#1A73E8]" : "bg-[#E5E7EB]"
}`} /> }`} />
)} )}
@@ -78,43 +63,16 @@ const OrderTracker: FC<{ status: OrderStatus }> = ({ status }) => {
); );
}; };
// ─── DashboardStep ─────────────────────────── // ─── DashboardStep ───────────────────────────
interface DashboardStepProps { interface DashboardStepProps {
session: ClientSession; session: ClientSession;
} }
const DashboardStep: FC<DashboardStepProps> = ({ session }) => { const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
const [orders, setOrders] = useState<Order[]>([]); const { orders, loading, error } = useClientOrders(session.id);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// جلب الطلبات من الـ API const activeOrder = orders.find(o => o.status === "CREATED" || o.status === "IN_TRANSIT");
useEffect(() => { const pastOrders = orders.filter(o => o !== activeOrder);
(async () => {
try {
const res = await fetch(`${API_BASE}/client/${session.id}/orders`);
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? "فشل جلب الطلبات");
// الـ API يُرجع: { orders: [...] } أو مصفوفة مباشرة
const list: Order[] = Array.isArray(data) ? data : (data.orders ?? []);
setOrders(list);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "تعذّر تحميل الطلبات");
} finally {
setLoading(false);
}
})();
}, [session.id]);
// أحدث طلب نشط (CREATED أو IN_TRANSIT)
const activeOrder = orders.find(
o => o.status === "CREATED" || o.status === "IN_TRANSIT"
);
const pastOrders = orders.filter(o => o !== activeOrder);
const summary = { const summary = {
total: orders.length, total: orders.length,
@@ -124,41 +82,38 @@ const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
return ( return (
<div dir="rtl"> <div dir="rtl">
{/* الرأس */} {/* Header */}
<div className="mb-6"> <div className="mb-6 flex items-center justify-between">
<div className="flex items-center justify-between mb-1"> <div>
<div> <h2 className="text-[22px] font-bold text-[#111827] tracking-tight">لوحة الطلبات</h2>
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight">لوحة الطلبات</h2> <p className="text-[13px] text-[#6B7280] mt-0.5">
<p className="text-[13px] text-[#6B7280] mt-0.5"> مرحبًا بعودتك، <strong className="text-[#111827]">{session.name}</strong>
مرحبًا بعودتك، <strong className="text-[#111827]">{session.name}</strong> </p>
</p> </div>
</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="الحساب نشط">
<div className="flex items-center gap-1.5 bg-[#D1FAE5] border border-[#A7F3D0] rounded-full px-3 py-1.5"> <span aria-hidden="true" className="w-2 h-2 rounded-full bg-[#10B981] motion-safe:animate-pulse" />
<span className="w-2 h-2 rounded-full bg-[#10B981] animate-pulse" /> <span className="text-[11px] font-bold text-[#065F46]">نشط</span>
<span className="text-[11px] font-bold text-[#065F46]">نشط</span>
</div>
</div> </div>
</div> </div>
{/* حالة التحميل أو الخطأ */} {/* Loading */}
{loading ? ( {loading && (
<div className="flex flex-col items-center justify-center py-14 gap-3 text-[#6B7280]"> <div className="flex flex-col items-center justify-center py-14 gap-3" role="status">
<Spinner /> <Spinner />
<p className="text-[13px]">جارٍ تحميل الطلبات</p> <p className="text-[13px] text-[#6B7280]">جارٍ تحميل الطلبات</p>
</div> </div>
) : error ? ( )}
<div className="flex flex-col items-center justify-center py-10 gap-3">
{/* Error */}
{error && (
<div className="flex flex-col items-center gap-3 py-10" role="alert">
<p className="text-[13px] text-red-500">{error}</p> <p className="text-[13px] text-red-500">{error}</p>
<button
onClick={() => { setLoading(true); setError(""); }}
className="text-[12px] text-[#1A73E8] underline"
>
إعادة المحاولة
</button>
</div> </div>
) : ( )}
{!loading && !error && (
<> <>
{/* إحصائيات */} {/* Stats */}
<div className="grid grid-cols-3 gap-3 mb-6"> <div className="grid grid-cols-3 gap-3 mb-6">
{[ {[
{ label: "إجمالي الطلبات", value: summary.total, color: "text-[#111827]" }, { label: "إجمالي الطلبات", value: summary.total, color: "text-[#111827]" },
@@ -172,7 +127,7 @@ const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
))} ))}
</div> </div>
{/* متتبع الطلب النشط */} {/* Active order tracker */}
{activeOrder ? ( {activeOrder ? (
<div className="bg-gradient-to-bl from-[#0D47A1] to-[#1A73E8] rounded-xl p-4 mb-6"> <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 className="flex items-center justify-between mb-3">
@@ -196,7 +151,7 @@ const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
</div> </div>
)} )}
{/* جدول الطلبات السابقة */} {/* Past orders table */}
{pastOrders.length > 0 && ( {pastOrders.length > 0 && (
<div className="bg-white border border-[#E5E7EB] rounded-xl overflow-hidden"> <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"> <div className="px-4 py-3 border-b border-[#E5E7EB] flex items-center justify-between">
@@ -208,16 +163,15 @@ const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
<thead> <thead>
<tr className="bg-[#F9FAFB]"> <tr className="bg-[#F9FAFB]">
{["رقم الطلب", "التاريخ", "الوصف", "الحالة", "المبلغ"].map(h => ( {["رقم الطلب", "التاريخ", "الوصف", "الحالة", "المبلغ"].map(h => (
<th key={h} className="px-4 py-2.5 text-right text-[10px] font-bold text-[#9CA3AF] uppercase tracking-wide">{h}</th> <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> </tr>
</thead> </thead>
<tbody> <tbody>
{pastOrders.map((order, i) => ( {pastOrders.map((order, i) => (
<tr <tr key={order.id} className={`border-t border-[#F3F4F6] hover:bg-[#FAFAFA] transition-colors ${i % 2 === 0 ? "" : "bg-[#FAFAFA]/30"}`}>
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"> <td className="px-4 py-3">
<span className="font-mono text-[12px] font-semibold text-[#1A73E8]">{order.id}</span> <span className="font-mono text-[12px] font-semibold text-[#1A73E8]">{order.id}</span>
</td> </td>
@@ -225,7 +179,7 @@ const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
<td className="px-4 py-3 text-[12px] text-[#374151]">{order.description}</td> <td className="px-4 py-3 text-[12px] text-[#374151]">{order.description}</td>
<td className="px-4 py-3"> <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 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 className="w-1.5 h-1.5 rounded-full bg-current" /> <span aria-hidden="true" className="w-1.5 h-1.5 rounded-full bg-current" />
{statusLabel(order.status)} {statusLabel(order.status)}
</span> </span>
</td> </td>
@@ -242,7 +196,6 @@ const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
</> </>
)} )}
{/* مسح الجلسة */}
<button <button
type="button" type="button"
onClick={() => { clearSession(); window.location.reload(); }} onClick={() => { clearSession(); window.location.reload(); }}

View File

@@ -0,0 +1,58 @@
"use client";
import { useEffect } from "react";
import { Spinner } from "../UI";
import type { User } from "../../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" }}
>
{/* أيقونة الحذف */}
<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>
);
}

56
Components/User/Toast.tsx Normal file
View File

@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
import type { Notification } from "../../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

@@ -0,0 +1,217 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Alert, Spinner } from "../UI";
import type { Branch } from "../../types/branch";
import type { Role } from "../../types/role";
import type { FormErrors, User, UserFormData } from "../../types/user";
// ── 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,
};
// ── macksure data validation ─────────────────────────────────────────────────────
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_RE = /^\+?[0-9]{10,15}$/;
function validate(data: UserFormData, isNew: boolean): FormErrors {
const e: FormErrors = {};
if (!data.name.trim()) e.name = "الاسم الكامل مطلوب";
if (data.email && !EMAIL_RE.test(data.email)) e.email = "صيغة البريد الإلكتروني غير صحيحة";
if (!data.phone.trim()) e.phone = "رقم الهاتف مطلوب";
else if (!PHONE_RE.test(data.phone.replace(/\s/g, ""))) e.phone = "رقم هاتف غير صالح (1015 رقم)";
if (isNew && !data.password) e.password = "كلمة المرور مطلوبة";
if (isNew && data.password && data.password.length < 6) e.password = "كلمة المرور 6 أحرف على الأقل";
if (!data.roleId) e.roleId = "الدور مطلوب";
if (!data.branchId) e.branchId = "الفرع مطلوب";
return e;
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface UserFormModalProps {
editUser: User | null;
roles: Role[];
branches: Branch[];
onClose: () => void;
onSubmit: (data: UserFormData, isNew: boolean) => Promise<boolean>;
}
// ── main component ────────────────────────────────────────────────────────────
export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }: UserFormModalProps) {
const isNew = editUser === null;
const [form, setForm] = useState<UserFormData>({
name: editUser?.name ?? "",
email: editUser?.email ?? "",
phone: editUser?.phone ?? "",
password: "",
roleId: editUser?.role?.id ?? "",
branchId: editUser?.branch?.id ?? "",
});
const [errors, setErrors] = useState<FormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const firstInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { firstInputRef.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 UserFormData) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setForm(p => ({ ...p, [field]: e.target.value }));
if (errors[field]) setErrors(p => ({ ...p, [field]: undefined }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// makesure data is valid before sending to API
const errs = validate(form, isNew);
if (Object.keys(errs).length) { setErrors(errs); return; }
// final check to prevent sending bad data to API (shouldn't happen because of validation, but just in case)
if (!form.name.trim() || !form.phone.trim() || !form.roleId || !form.branchId) {
setApiError("يرجى التأكد من إدخال جميع البيانات المطلوبة.");
return;
}
setSaving(true);
setApiError("");
const ok = await onSubmit(form, isNew);
setSaving(false);
if (ok) onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
};
// dynamic styles for inputs with errors
const inputStyle = (field: keyof FormErrors): React.CSSProperties => ({
...S.input,
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
return (
<div
role="dialog" aria-modal="true" aria-labelledby="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="modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{isNew ? "مستخدم جديد" : editUser?.name}
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
{/* body*/}
<form onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
{/* name field*/}
<label style={S.label}>
الاسم الكامل *
<input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="أحمد الرشيدي" autoComplete="name" dir="rtl" />
{errors.name && <span style={S.errorText}>{errors.name}</span>}
</label>
{/* email and phone fields */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
البريد الإلكتروني
<input style={inputStyle("email")} type="email" value={form.email} onChange={set("email")} placeholder="ahmed@co.sa" autoComplete="email" dir="ltr" />
{errors.email && <span style={S.errorText}>{errors.email}</span>}
</label>
<label style={S.label}>
رقم الهاتف *
<input style={inputStyle("phone")} type="tel" value={form.phone} onChange={set("phone")} placeholder="+966 5x xxx xxxx" autoComplete="tel" dir="ltr" />
{errors.phone && <span style={S.errorText}>{errors.phone}</span>}
</label>
</div>
{/* password field */}
<label style={S.label}>
{isNew ? "كلمة المرور *" : "كلمة المرور الجديدة (اتركها فارغة إذا لا تريد تغييرها)"}
<input style={inputStyle("password")} type="password" value={form.password} onChange={set("password")} placeholder="••••••••" autoComplete={isNew ? "new-password" : "off"} dir="ltr" />
{errors.password && <span style={S.errorText}>{errors.password}</span>}
</label>
{/* role and branch fields */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
الدور *
<select style={{ ...inputStyle("roleId"), cursor: "pointer" }} value={form.roleId} onChange={set("roleId")} dir="rtl">
<option value="">اختر الدور</option>
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
{errors.roleId && <span style={S.errorText}>{errors.roleId}</span>}
</label>
<label style={S.label}>
الفرع *
<select style={{ ...inputStyle("branchId"), cursor: "pointer" }} value={form.branchId} onChange={set("branchId")} dir="rtl">
<option value="">اختر الفرع</option>
{branches.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
{errors.branchId && <span style={S.errorText}>{errors.branchId}</span>}
</label>
</div>
{/* action buttons */}
<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

@@ -0,0 +1,157 @@
"use client";
import { Spinner } from "../UI";
import type { User } from "../../types/user";
// ── role ────────────────────────────────────────────────────────────────
function RoleBadge({ name }: { name?: string }) {
const isAdmin = name === "مدير النظام";
return (
<span style={{ display: "inline-flex", alignItems: "center", borderRadius: "var(--radius-full)", border: isAdmin ? "1px solid #BFDBFE" : "1px solid var(--color-border)", background: isAdmin ? "#EFF6FF" : "var(--color-surface-muted)", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: isAdmin ? "#1D4ED8" : "var(--color-text-muted)" }}>
{name ?? "—"}
</span>
);
}
// ── status ───────────────────────────────────────────────────────────────
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
// icon buton ───────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── title style ───────────────────────────────────────────────────
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 {
users: User[];
loading: boolean;
search: string;
page: number;
pages: number;
onEdit: (user: User) => void;
onDelete: (user: User) => void;
onAddFirst: () => void;
onPageChange: (p: number) => void;
}
// ── main table ────────────────────────────────────────────────────────────
export function UserTable({ users, loading, search, page, pages, onEdit, onDelete, onAddFirst, onPageChange }: UserTableProps) {
return (
<div style={cardStyle}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 80px", ...thStyle }}>
<span>الاسم</span>
<span>اسم المستخدم</span>
<span>الفرع</span>
<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>
) : users.length === 0 ? (
// empty state
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."}
</p>
{!search && (
<button type="button" onClick={onAddFirst}
style={{ marginTop: 12, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
أضف أول مستخدم
</button>
)}
</div>
) : (
// data rows
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{users.map((u, i) => (
<li key={u.id} style={{
display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 80px",
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>
<RoleBadge name={u.role?.name} />
<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: 6 }}>
<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>
<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" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,25 +1,25 @@
"use client"; "use client";
// ─────────────────────────────────────────────
// page.tsx
// نقطة الدخول — تحقق من الجلسة وتوجيه المستخدم
// ─────────────────────────────────────────────
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { loadSession, type ClientSession } from "@/utils/helperFun"; import { loadSession, type ClientSession } from "@/lib/session";
import RegisterStep from "../../Components/Client/client"; import RegisterStep from "../..//Components/Client/client";
import DashboardStep from "../../Components/Order/order"; import DashboardStep from "../../Components/Order/order";
import Logo from "@/utils/logo"; import AddressStep from "../../Components/Client_Adress/clientAdress";
import Logo from "../../utils/logo";
import { Spinner } from "../../Components/UI";
type Step = "bootstrap" | "register" | "dashboard"; type Step = "bootstrap" | "register" | "address" | "dashboard";
/* شريط التنقل */
function Navbar({ session }: { session: ClientSession | null }) { function Navbar({ session }: { session: ClientSession | null }) {
return ( return (
<nav className="bg-white border-b border-[#E5E7EB] flex items-center justify-between px-5 h-14 sticky top-0 z-30"> <nav
aria-label="Client portal navigation"
className="bg-white border-b border-[#E5E7EB] flex items-center justify-between px-5 h-14 sticky top-0 z-30"
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{session?.name && ( {session?.name && (
<span className="text-[12px] text-[#6B7280] hidden sm:block">{session.name}</span> <span className="text-[12px] text-[#6B7280] hidden sm:block">{session.name}</span>
)} )}
<div className="w-8 h-8 rounded-full bg-[#EBF3FF] flex items-center justify-center"> <div aria-hidden="true" className="w-8 h-8 rounded-full bg-[#EBF3FF] flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1A73E8" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1A73E8" strokeWidth="2">
<circle cx="12" cy="8" r="4"/> <circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/> <path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
@@ -31,35 +31,26 @@ function Navbar({ session }: { session: ClientSession | null }) {
); );
} }
/* شاشة التحميل */
function BootstrapScreen() {
return (
<div className="flex flex-col items-center justify-center py-10 gap-3">
<div className="w-10 h-10 rounded-full border-4 border-[#1A73E8] border-t-transparent animate-spin" />
<p className="text-[13px] text-[#6B7280]">جارٍ تحميل بيانات الجلسة</p>
</div>
);
}
export default function ClientOrderPage() { export default function ClientOrderPage() {
const [step, setStep] = useState<Step>("bootstrap"); const [step, setStep] = useState<Step>("bootstrap");
const [session, setSession] = useState<ClientSession | null>(null); const [session, setSession] = useState<ClientSession | null>(null);
useEffect(() => { useEffect(() => {
const saved = loadSession(); const saved = loadSession();
if (saved?.id && saved.name) { if (saved?.id && saved.name) {
// user has valid session → dashboard
setSession(saved); setSession(saved);
setStep("dashboard"); setStep("dashboard");
} else { } else {
// new or invalid session → register
setStep("register"); setStep("register");
} }
}, []); }, []);
const handleRegistered = useCallback((s: ClientSession) => { const handleRegistered = useCallback((s: ClientSession) => {
setSession(s); setSession(s);
setStep("address");
}, []);
const handleAddressComplete = useCallback(() => {
setStep("dashboard"); setStep("dashboard");
}, []); }, []);
@@ -68,18 +59,28 @@ export default function ClientOrderPage() {
className="min-h-screen bg-[#ECEEF2]" className="min-h-screen bg-[#ECEEF2]"
dir="rtl" dir="rtl"
lang="ar" lang="ar"
style={{ fontFamily: "'Cairo', 'IBM Plex Sans Arabic', Tahoma, sans-serif" }} style={{ fontFamily: "var(--font-sans)" }}
> >
<Navbar session={session} /> <Navbar session={session} />
<div className="max-w-lg mx-auto px-4 py-8"> <div className="max-w-lg mx-auto px-4 py-8">
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 shadow-sm"> <div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 shadow-sm">
{step === "bootstrap" && <BootstrapScreen />}
{step === "bootstrap" && (
<div className="flex flex-col items-center justify-center py-10 gap-3" role="status">
<Spinner size="lg" />
<p className="text-[13px] text-[#6B7280]">جارٍ تحميل بيانات الجلسة</p>
</div>
)}
{step === "register" && ( {step === "register" && (
<RegisterStep onSuccess={handleRegistered} /> <RegisterStep onSuccess={handleRegistered} />
)} )}
{step === "address" && session && (
<AddressStep session={session} onSuccess={handleAddressComplete} />
)}
{step === "dashboard" && session && ( {step === "dashboard" && session && (
<DashboardStep session={session} /> <DashboardStep session={session} />
)} )}

View File

@@ -1,3 +1,417 @@
export default function CarsPage() { "use client";
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Cars module is ready for the fleet management dashboard.</section>;
import { useEffect, useState } from "react";
import { getStoredToken } from "../../lib/auth";
import { get } from "../../services/api";
import { Spinner, Alert } from "../../Components/UI";
interface Car {
id: string;
manufacturer: string;
model: string;
year: number;
color?: string;
plateNumber: string;
plateLetters: string;
currentStatus: "Active" | "InMaintenance" | "InTrip" | "Inactive";
insuranceStatus?: "Valid" | "Expired" | "NotInsured";
insuranceExpiryDate?: string;
registrationExpiryDate?: string;
branch?: { name: string };
createdAt: string;
}
interface ApiResponse {
data: {
data: Car[];
pagination: { total: number; page: number; pages: number };
meta?: { total: number; pages: number };
};
}
const STATUS_MAP: Record<
Car["currentStatus"],
{ label: string; color: string; bg: string; border: string }
> = {
Active: { label: "نشط", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0" },
InMaintenance: {
label: "صيانة",
color: "#854D0E",
bg: "#FFFBEB",
border: "#FDE68A",
},
InTrip: {
label: "في رحلة",
color: "#1E40AF",
bg: "#EFF6FF",
border: "#BFDBFE",
},
Inactive: {
label: "غير نشط",
color: "#64748B",
bg: "#F1F5F9",
border: "#E2E8F0",
},
};
const INS_MAP: Record<string, { label: string; color: string }> = {
Valid: { label: "سارٍ", color: "#16A34A" },
Expired: { label: "منتهي", color: "#DC2626" },
NotInsured: { label: "غير مؤمَّن", color: "#D97706" },
};
function fmtDate(iso?: string) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric",
month: "short",
day: "numeric",
});
}
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 default function CarsPage() {
const [cars, setCars] = useState<Car[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
useEffect(() => {
const token = getStoredToken();
setLoading(true);
setError(null);
const query = `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
get<ApiResponse>(`cars${query}`, token)
.then((res) => {
const payload = res.data ?? res;
setCars(payload.data ?? []);
setTotal(payload.meta?.total ?? 0);
setPages(payload.meta?.pages ?? 1);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [page, search]);
return (
<section
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
>
{/* 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)",
}}
>
<p
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
}}
>
إدارة الأسطول
</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,
}}
>
السيارات
</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={{ position: "relative", width: "100%", maxWidth: 288 }}>
<svg
style={{
position: "absolute",
right: 12,
top: "50%",
transform: "translateY(-50%)",
width: 16,
height: 16,
color: "var(--color-text-hint)",
pointerEvents: "none",
}}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالماركة أو اللوحة..."
value={search}
onChange={(e) => {
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>
</div>
</header>
{error && (
<Alert type="error" message={error} onClose={() => setError(null)} />
)}
{/* Table */}
<div style={cardStyle}>
<div
dir="rtl"
style={{
display: "grid",
gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 1fr",
...thStyle,
}}
>
<span>السيارة</span>
<span>اللوحة</span>
<span>الفرع</span>
<span>الحالة</span>
<span>التأمين</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>
) : cars.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 }}>
{cars.map((c, i) => {
const status = STATUS_MAP[c.currentStatus];
const ins = c.insuranceStatus ? INS_MAP[c.insuranceStatus] : null;
return (
<li
key={c.id}
style={{
display: "grid",
gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 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>
<p
style={{
fontWeight: 600,
color: "var(--color-text-primary)",
margin: 0,
}}
>
{c.manufacturer} {c.model}
</p>
<p
style={{
marginTop: 2,
fontSize: 11,
color: "var(--color-text-muted)",
}}
>
{c.year}
{c.color ? ` · ${c.color}` : ""}
</p>
</div>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 13,
fontWeight: 700,
letterSpacing: "0.08em",
color: "#2563EB",
}}
>
{c.plateLetters} {c.plateNumber}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{c.branch?.name ?? "—"}
</span>
<span
style={{
display: "inline-flex",
alignItems: "center",
borderRadius: "var(--radius-full)",
border: `1px solid ${status.border}`,
background: status.bg,
padding: "0.2rem 0.625rem",
fontSize: 11,
fontWeight: 600,
color: status.color,
width: "fit-content",
}}
>
{status.label}
</span>
<span
style={{
fontSize: 12,
fontWeight: 600,
color: ins?.color ?? "var(--color-text-muted)",
}}
>
{ins?.label ?? "—"}
</span>
<span
style={{
textAlign: "center",
fontSize: 11,
color: "var(--color-text-muted)",
}}
>
{fmtDate(c.registrationExpiryDate)}
</span>
</li>
);
})}
</ul>
)}
{pages > 1 && (
<div
dir="rtl"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderTop: "1px solid var(--color-border)",
padding: "0.875rem 1.5rem",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{page}
</strong>{" "}
من{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{pages}
</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{
label: "السابق",
action: () => setPage((p) => Math.max(1, p - 1)),
disabled: page === 1,
},
{
label: "التالي",
action: () => setPage((p) => Math.min(pages, p + 1)),
disabled: page === pages,
},
].map((btn) => (
<button
key={btn.label}
onClick={btn.action}
disabled={btn.disabled}
style={{
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
padding: "0.375rem 0.875rem",
fontSize: 12,
color: "var(--color-text-secondary)",
cursor: btn.disabled ? "not-allowed" : "pointer",
opacity: btn.disabled ? 0.4 : 1,
fontFamily: "var(--font-sans)",
}}
>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
</section>
);
} }

View File

@@ -1,74 +1,130 @@
// File: app/components/layout/Sidebar.tsx
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
// FIX: Import getStoredUser so the sidebar reads the authenticated user's import { usePathname } from "next/navigation";
// name and role from localStorage instead of showing hardcoded placeholders.
import { getStoredUser } from "../../../lib/auth"; import { getStoredUser } from "../../../lib/auth";
import Logo from "../../../utils/logo";
const navItems = [ const navSections = [
{ href: "/dashboard", label: "Dashboard", permission: "read-dashboard" }, {
{ href: "/users", label: "Users", permission: "read-user" }, label: "الرئيسية",
{ href: "/cars", label: "Cars", permission: "read-car" }, items: [
{ href: "/drivers", label: "Drivers", permission: "read-driver" }, { href: "/dashboard", label: "Dashboard", icon: "ti-layout-dashboard", permission: "read-dashboard" },
{ href: "/clients", label: "Clients", permission: "read-client" }, { href: "/users", label: "Users", icon: "ti-users", permission: "read-user" },
{ href: "/orders", label: "Orders", permission: "read-order" }, ],
{ href: "/trips", label: "Trips", permission: "read-trip" }, },
{ href: "/branches", label: "Branches", permission: "read-branch" }, {
{ href: "/roles", label: "Roles", permission: "read-role" }, label: "الأسطول",
{ href: "/audit", label: "Audit", permission: "read-audit" }, items: [
{ href: "/cars", label: "Cars", icon: "ti-car", permission: "read-car" },
{ href: "/drivers", label: "Drivers", icon: "ti-steering-wheel",permission: "read-driver" },
{ href: "/trips", label: "Trips", icon: "ti-route", permission: "read-trip" },
],
},
{
label: "العمليات",
items: [
{ href: "/orders", label: "Orders", icon: "ti-package", permission: "read-order" },
{ href: "/clients", label: "Clients", icon: "ti-users-group", permission: "read-client" },
{ href: "/branches", label: "Branches", icon: "ti-building", permission: "read-branch" },
{ href: "/roles", label: "Roles", icon: "ti-shield", permission: "read-role" },
{ href: "/audit", label: "Audit", icon: "ti-clipboard-list", permission: "read-audit" },
],
},
]; ];
export function Sidebar() { export function Sidebar() {
// FIX: Read the stored user on render. suppressHydrationWarning handles const pathname = usePathname();
// the SSR/CSR mismatch since localStorage is browser-only. const user = getStoredUser();
const user = getStoredUser(); const permissions = user?.permissions ?? navSections.flatMap(s => s.items.map(i => i.permission));
const permissions = user?.permissions ?? navItems.map((i) => i.permission);
return ( return (
<aside <aside
suppressHydrationWarning suppressHydrationWarning
className="hidden w-72 shrink-0 border-r border-white/10 bg-slate-950/80 p-6 lg:flex lg:flex-col" className="hidden lg:flex lg:flex-col"
style={{
width: "var(--sidebar-width)",
flexShrink: 0,
background: "linear-gradient(175deg, #1E3A8A 0%, #1D4ED8 60%, #1565C0 100%)",
minHeight: "100vh",
padding: "20px 12px",
}}
> >
<div> {/* Logo */}
<p className="text-xs uppercase tracking-[0.35em] text-cyan-200">Logistics</p> <div style={{ marginBottom: 28 }}>
<h2 className="mt-3 text-xl font-semibold text-white">Ops Console</h2> <Logo white />
<p className="mt-2 text-sm text-slate-300">
Fleet, clients, orders, and compliance in one place.
</p>
</div> </div>
<nav className="mt-8 space-y-2"> {/* Nav sections */}
{navItems.map((item) => { <nav
// FIX: Check the real user permissions array fetched from stored auth. style={{ flex: 1, display: "flex", flexDirection: "column", gap: 0 }}
// Dashboard is always visible regardless of permissions. aria-label="Main navigation"
const hasPermission = >
item.permission === "read-dashboard" || {navSections.map((section) => (
permissions.includes(item.permission); <div key={section.label} style={{ marginBottom: 8 }}>
if (!hasPermission) return null; <p style={{
fontSize: 9,
letterSpacing: "0.14em",
textTransform: "uppercase",
color: "rgba(255,255,255,0.45)",
padding: "8px 8px 4px",
fontWeight: 600,
}}>
{section.label}
</p>
return ( {section.items.map((item) => {
<Link const allowed = item.permission === "read-dashboard"
key={item.href} || permissions.includes(item.permission);
href={item.href} if (!allowed) return null;
className="flex items-center justify-between rounded-2xl border border-white/8 bg-slate-900/70 px-4 py-3 text-sm text-slate-200 transition hover:border-cyan-400/30 hover:bg-slate-800"
> const active = pathname === item.href
<span>{item.label}</span> || (item.href !== "/dashboard" && pathname.startsWith(item.href));
<span className="text-xs text-slate-400"></span>
</Link> return (
); <Link
})} key={item.href}
href={item.href}
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "7px 8px",
borderRadius: 8,
fontSize: 13,
fontWeight: active ? 500 : 400,
color: active ? "#FFFFFF" : "rgba(255,255,255,0.55)",
background: active ? "rgba(255,255,255,0.15)" : "transparent",
textDecoration: "none",
transition: "var(--transition-base)",
marginBottom: 2,
}}
>
<i
className={`ti ${item.icon}`}
aria-hidden="true"
style={{ fontSize: 16 }}
/>
{item.label}
</Link>
);
})}
</div>
))}
</nav> </nav>
<div className="mt-auto rounded-3xl border border-emerald-400/20 bg-emerald-400/10 p-4 text-sm text-emerald-50"> {/* User badge */}
<p className="text-xs uppercase tracking-[0.25em] text-emerald-100">Authenticated</p> <div style={{
{/* FIX: Show the real user name and role from the stored auth session, background: "rgba(255,255,255,0.12)",
replacing the previous hardcoded "Operations user" / "Signed in" text. */} borderRadius: 10,
<p suppressHydrationWarning className="mt-2 font-semibold"> padding: "10px 12px",
marginTop: 8,
}}>
<p suppressHydrationWarning style={{ fontSize: 13, fontWeight: 500, color: "#fff", margin: 0 }}>
{user?.name ?? user?.userName ?? "—"} {user?.name ?? user?.userName ?? "—"}
</p> </p>
<p suppressHydrationWarning className="text-xs text-emerald-100/90"> <p suppressHydrationWarning style={{ fontSize: 11, color: "rgba(255,255,255,0.55)", marginTop: 2 }}>
{user?.role ? `Role: ${user.role}` : "Signed in"} {user?.role ?? "Signed in"}
</p> </p>
</div> </div>
</aside> </aside>

View File

@@ -1,68 +1,72 @@
// File: app/components/layout/Topbar.tsx
"use client"; "use client";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
// FIX: Import both clearAuth and getStoredUser so the topbar can show the
// authenticated user's role and handle logout properly.
import { clearAuth, getStoredUser } from "../../../lib/auth"; import { clearAuth, getStoredUser } from "../../../lib/auth";
export function Topbar() { export function Topbar() {
const router = useRouter(); const router = useRouter();
function handleLogout() { function handleLogout() {
// FIX: clearAuth already existed and is correct — it removes both the
// token and user from localStorage, fully ending the session.
clearAuth(); clearAuth();
router.replace("/login"); router.replace("/login");
} }
// FIX: Read the stored user to display their real role in the header
// instead of the hardcoded "Manager" placeholder.
const user = getStoredUser(); const user = getStoredUser();
// Capitalize the role label for display (e.g. "admin" → "Admin")
const roleLabel = user?.role
? user.role.charAt(0).toUpperCase() + user.role.slice(1)
: "—";
return ( return (
<header <header
suppressHydrationWarning suppressHydrationWarning
className="border-b border-white/10 bg-slate-950/70 px-4 py-4 lg:px-6" style={{
height: "var(--topbar-height)",
background: "#FFFFFF",
borderBottom: "1px solid var(--color-border)",
padding: "0 24px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
> >
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4"> <div>
<div> <p style={{ fontSize: 14, fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
<p className="text-xs uppercase tracking-[0.35em] text-cyan-200"> لوحة التحكم
Operations dashboard </p>
</p> <p style={{ fontSize: 11, color: "var(--color-text-muted)", margin: 0 }}>
<h1 className="text-xl font-semibold text-white lg:text-2xl"> Operations dashboard
Logistics delivery management </p>
</h1> </div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div
suppressHydrationWarning
style={{
fontSize: 12,
color: "var(--color-text-secondary)",
background: "var(--color-surface-muted)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-full)",
padding: "4px 12px",
}}
>
{user?.role ?? "—"}
</div> </div>
<div className="flex items-center gap-3"> <button
<Link type="button"
href="/" onClick={handleLogout}
className="rounded-full border border-white/10 bg-slate-900/80 px-4 py-2 text-sm text-slate-200 hover:bg-slate-800" style={{
> fontSize: 12,
Overview fontWeight: 500,
</Link> color: "#DC2626",
{/* FIX: Replace the hardcoded "Manager" string with the actual background: "#FEF2F2",
authenticated user's role read from localStorage. */} border: "1px solid #FECACA",
<div borderRadius: "var(--radius-full)",
suppressHydrationWarning padding: "5px 14px",
className="hidden rounded-full border border-white/10 bg-slate-900/80 px-4 py-2 text-sm text-slate-200 md:block" cursor: "pointer",
> transition: "var(--transition-base)",
{roleLabel} }}
</div> >
<button تسجيل الخروج
type="button" </button>
onClick={handleLogout}
className="rounded-full bg-rose-500/10 px-4 py-2 text-sm text-rose-100 hover:bg-rose-500/20"
>
Logout
</button>
</div>
</div> </div>
</header> </header>
); );

View File

@@ -1,16 +1,18 @@
import { Sidebar } from "../components/layout/Sidebar"; import { Sidebar } from "../components/layout/Sidebar";
import { Topbar } from "../components/layout/Topbar"; import { Topbar } from "../components/layout/Topbar";
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return ( return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,#0f172a_0%,#111827_45%,#020617_100%)] text-slate-100"> <div className="flex min-h-screen" style={{ background: "var(--color-surface-muted)" }}>
<div className="flex min-h-screen"> <Sidebar />
<Sidebar /> <div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-w-0 flex-1 flex-col"> <Topbar />
<Topbar /> <main className="flex-1 p-6">{children}</main>
<main className="flex-1 p-4 lg:p-6">{children}</main>
</div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -2,160 +2,219 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo } from "react";
import { getDashboardSummary, type DashboardSummaryResponse } from "../../lib/api"; import { useDashboardSummary } from "../../hooks/useDashboardSummary";
import { clearAuth, getStoredToken, getStoredUser } from "../../lib/auth"; import { clearAuth, getStoredUser } from "../../lib/auth";
import { Spinner, Alert } from "../../Components/UI";
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
const [data, setData] = useState<DashboardSummaryResponse | null>(null); const { data, error, loading } = useDashboardSummary();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
if (!getStoredToken()) { const storedUser = getStoredUser();
router.replace("/login");
return;
}
// FIX: Implement role-based access control by checking the authenticated user's role from localStorage. Only users with "user" or "admin" roles can access the dashboard. If the role is missing or not allowed, clear the auth state and redirect to login.
const storedUser = getStoredUser(); // add this import at top of file
const role = typeof storedUser?.role === "string" ? storedUser.role : ""; const role = typeof storedUser?.role === "string" ? storedUser.role : "";
if (!role || ["driver", "سائق"].includes(role)) { if (!role || ["driver", "سائق"].includes(role)) {
clearAuth(); // add this import at top of file clearAuth();
router.replace("/login"); router.replace("/login");
return;
} }
let mounted = true;
async function load() {
try {
const summary = await getDashboardSummary();
if (mounted) setData(summary);
} catch (err) {
if (mounted) setError(err instanceof Error ? err.message : "Unable to load dashboard data.");
} finally {
if (mounted) setLoading(false);
}
}
load();
return () => {
mounted = false;
};
}, [router]); }, [router]);
const stats = data?.data?.stats; const stats = data?.stats;
const alerts = data?.data?.alerts; const alerts = data?.alerts;
const activeTrips = data?.data?.activeTrips || []; const activeTrips = data?.activeTrips || [];
const summaryCards = useMemo( const summaryCards = useMemo(
() => [ () => [
{ label: "العملاء", value: stats?.clients ?? 0, tone: "from-cyan-500 to-sky-600" }, { label: "العملاء", value: stats?.clients ?? 0, accent: "#06B6D4" },
{ label: "الطلبات", value: stats?.orders ?? 0, tone: "from-violet-500 to-fuchsia-600" }, { label: "الطلبات", value: stats?.orders ?? 0, accent: "#A78BFA" },
{ label: "الرحلات", value: stats?.trips ?? 0, tone: "from-emerald-500 to-green-600" }, { label: "الرحلات", value: stats?.trips ?? 0, accent: "#34D399" },
{ label: "المركبات", value: (stats?.cars ?? 0) + (stats?.drivers ?? 0), tone: "from-amber-400 to-orange-500" }, { label: "المركبات", value: (stats?.cars ?? 0) + (stats?.drivers ?? 0), accent: "#FBBF24" },
], ],
[stats], [stats],
); );
return ( return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,#0f172a_0%,#111827_45%,#020617_100%)] text-slate-100"> <section className="flex flex-col gap-6">
<section className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-6 py-10 lg:px-8">
<header className="rounded-3xl border border-white/10 bg-white/6 p-8 shadow-2xl shadow-slate-950/30 backdrop-blur-xl"> {/* ── Header ── */}
<p className="text-sm uppercase tracking-[0.35em] text-cyan-200">لوحة العمليات</p> <header
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> style={{
<div> background: "rgba(255,255,255,0.04)",
<h1 className="text-3xl font-semibold text-white lg:text-4xl">ذكاء الأسطول في لمحة سريعة</h1> border: "1px solid var(--color-border-dark)",
<p className="mt-3 max-w-3xl text-slate-300">هذه الشاشة متوافقة مع نقطة ملخص لوحة الإدارة في الخلفية وتوفر عرضًا واضحًا لطلب الأسطول، وتنبيهات السلامة، وتقدم الرحلات.</p> borderRadius: "var(--radius-2xl)",
</div> padding: "2rem",
<Link href="/" className="inline-flex items-center rounded-full border border-cyan-400/30 bg-cyan-400/10 px-4 py-2 text-sm text-cyan-100 transition hover:bg-cyan-400/20">العودة إلى بوابة العميل</Link> boxShadow: "var(--shadow-overlay)",
backdropFilter: "blur(16px)",
}}
>
<p style={{ fontSize: 11, letterSpacing: "0.35em", textTransform: "uppercase", color: "#67E8F9" }}>
لوحة العمليات
</p>
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 style={{ fontSize: "clamp(1.5rem,3vw,2rem)", fontWeight: 600, color: "var(--color-text-dark-primary)", margin: 0 }}>
ذكاء الأسطول في لمحة سريعة
</h1>
<p style={{ marginTop: "0.75rem", maxWidth: 680, color: "var(--color-text-dark-muted)", fontSize: 14, lineHeight: 1.6 }}>
عرض واضح لطلبات الأسطول وتنبيهات السلامة وتقدم الرحلات.
</p>
</div> </div>
</header> <Link
href="/"
style={{
display: "inline-flex",
alignItems: "center",
borderRadius: "var(--radius-full)",
border: "1px solid rgba(103,232,249,0.30)",
background: "rgba(103,232,249,0.08)",
padding: "0.5rem 1rem",
fontSize: 13,
color: "#CFFAFE",
textDecoration: "none",
whiteSpace: "nowrap",
transition: "var(--transition-base)",
}}
>
العودة إلى بوابة العميل
</Link>
</div>
</header>
{loading && <p className="text-slate-300">جارٍ تحميل مقاييس الخلفية</p>} {/* ── Loading ── */}
{error && <p className="rounded-2xl border border-rose-400/30 bg-rose-500/10 p-4 text-rose-100">{error}</p>} {loading && (
<div style={{ display: "flex", alignItems: "center", gap: 12, color: "var(--color-text-dark-muted)" }}>
<Spinner size="sm" className="text-cyan-400" />
<span style={{ fontSize: 14 }}>جارٍ تحميل البيانات</span>
</div>
)}
{!loading && !error && ( {/* ── Error ── */}
<> {error && (
<section className="grid gap-6 md:grid-cols-2 xl:grid-cols-4"> <Alert type="error" message={error}
{summaryCards.map((item) => ( className="border-rose-400/30 bg-rose-500/10 text-rose-100" />
<article key={item.label} className="rounded-3xl border border-white/10 bg-slate-900/80 p-5 shadow-xl shadow-slate-950/20"> )}
<div className={`h-2 rounded-full bg-linear-to-r ${item.tone}`} />
<p className="mt-4 text-sm uppercase tracking-[0.25em] text-slate-400">{item.label}</p>
<p className="mt-2 text-4xl font-semibold text-white">{item.value}</p>
</article>
))}
</section>
<section className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]"> {!loading && !error && (
<article className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20"> <>
<div className="flex items-center justify-between"> {/* ── Summary cards ── */}
<div> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<p className="text-sm uppercase tracking-[0.25em] text-cyan-200">الرحلات النشطة</p> {summaryCards.map((item) => (
<h2 className="mt-2 text-xl font-semibold text-white">تقدم الرحلات</h2> <article
</div> key={item.label}
<span className="rounded-full bg-emerald-400/10 px-3 py-1 text-xs text-emerald-200">مباشر من /dashboard/summary</span> style={{
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border-dark)",
background: "var(--color-surface-dark-card)",
padding: "1.25rem",
boxShadow: "var(--shadow-card)",
}}
>
<div style={{ height: 3, borderRadius: 999, background: item.accent, marginBottom: "1rem" }} />
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-dark-muted)" }}>
{item.label}
</p>
<p style={{ fontSize: "2.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>
{item.value}
</p>
</article>
))}
</div>
{/* ── Active trips + alerts ── */}
<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)" }}>
<div className="flex items-center justify-between">
<div>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#67E8F9" }}>الرحلات النشطة</p>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>تقدم الرحلات</h2>
</div> </div>
<div className="mt-6 space-y-4"> <span style={{ borderRadius: "var(--radius-full)", background: "rgba(52,211,153,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#A7F3D0" }}>
{activeTrips.length ? activeTrips.map((trip) => ( مباشر
<div key={trip.id} className="rounded-2xl border border-white/10 bg-slate-800/80 p-4"> </span>
<div className="flex items-center justify-between gap-3"> </div>
<div> <div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
<p className="text-sm font-semibold text-white">{trip.tripNumber}</p> {activeTrips.length ? activeTrips.map((trip) => (
<p className="text-sm text-slate-300">{trip.title}</p> <div key={trip.id} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "1rem" }}>
</div> <div className="flex items-center justify-between gap-3">
<span className="rounded-full bg-cyan-400/10 px-3 py-1 text-xs text-cyan-100">{trip.progress}%</span> <div>
</div> <p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)" }}>{trip.tripNumber}</p>
<div className="mt-3 h-2 rounded-full bg-slate-700"> <p style={{ fontSize: 13, color: "var(--color-text-dark-muted)" }}>{trip.title}</p>
<div className="h-2 rounded-full bg-linear-to-r from-cyan-400 to-emerald-400" style={{ width: `${trip.progress}%` }} />
</div> </div>
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(103,232,249,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#CFFAFE" }}>
{trip.progress}%
</span>
</div> </div>
)) : <p className="text-sm text-slate-300">لا توجد رحلات في-progress حالياً.</p>} <div style={{ marginTop: "0.75rem", height: 6, borderRadius: 999, background: "rgba(255,255,255,0.08)" }}>
</div> <div style={{ height: 6, borderRadius: 999, width: `${trip.progress}%`, background: "linear-gradient(90deg,#06B6D4,#34D399)" }} />
</article> </div>
</div>
)) : (
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)" }}>لا توجد رحلات نشطة حالياً.</p>
)}
</div>
</article>
<article className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20"> <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 className="text-sm uppercase tracking-[0.25em] text-amber-200">تنبيهات الالتزام</p> <p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#FCD34D" }}>تنبيهات الالتزام</p>
<h2 className="mt-2 text-xl font-semibold text-white">التجديدات القادمة</h2> <h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>التجديدات القادمة</h2>
<div className="mt-6 space-y-4 text-sm text-slate-200"> <div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{(alerts?.expiringCars || []).slice(0, 3).map((item, index) => ( {(alerts?.expiringCars || []).slice(0, 3).map((item, i) => (
<div key={`car-${index}`} className="rounded-2xl border border-amber-400/20 bg-amber-400/8 p-4">{String((item as Record<string, unknown>).message || "Vehicle expiry alert")}</div> <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)" }}>
))} {String((item as Record<string, unknown>).message || "Vehicle expiry alert")}
{(alerts?.expiringDrivers || []).slice(0, 3).map((item, index) => ( </div>
<div key={`driver-${index}`} className="rounded-2xl border border-rose-400/20 bg-rose-500/8 p-4">{String((item as Record<string, unknown>).message || "Driver expiry alert")}</div> ))}
))} {(alerts?.expiringDrivers || []).slice(0, 3).map((item, i) => (
{(alerts?.upcomingMaint || []).slice(0, 3).map((item, index) => ( <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)" }}>
<div key={`maint-${index}`} className="rounded-2xl border border-cyan-400/20 bg-cyan-400/8 p-4">{String((item as Record<string, unknown>).message || "Maintenance alert")}</div> {String((item as Record<string, unknown>).message || "Driver expiry alert")}
))} </div>
</div> ))}
</article> {(alerts?.upcomingMaint || []).slice(0, 3).map((item, i) => (
</section> <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)" }}>
{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)" }}>لا توجد تنبيهات حالياً.</p>
)}
</div>
</article>
</div>
<section className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]"> {/* ── Security + endpoints ── */}
<article className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20"> <div className="grid gap-6 lg:grid-cols-2">
<p className="text-sm uppercase tracking-[0.25em] text-violet-200">الأمان</p> <article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
<h2 className="mt-2 text-xl font-semibold text-white">لمحة الحساب</h2> <p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#C4B5FD" }}>الأمان</p>
<div className="mt-6 space-y-4"> <h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>لمحة الحساب</h2>
<div className="rounded-2xl border border-white/10 bg-slate-800/80 p-4 text-sm text-slate-200">آخر تسجيل دخول: {data?.data?.accountSecurity?.lastLogin || "لم يتم العثور على حدث تسجيل دخول"}</div> <div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div className="rounded-2xl border border-white/10 bg-slate-800/80 p-4 text-sm text-slate-200">بيانات الجهاز: {data?.data?.accountSecurity?.requestMeta ? JSON.stringify(data.data.accountSecurity.requestMeta) : "غير متاح"}</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)" }}>
آخر تسجيل دخول: {data?.accountSecurity?.lastLogin || "—"}
</div> </div>
</article> <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)" }}>
بيانات الجهاز: {data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"}
</div>
</div>
</article>
<article className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20"> <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 className="text-sm uppercase tracking-[0.25em] text-emerald-200">خريطة الخلفية</p> <p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#6EE7B7" }}>خريطة الخلفية</p>
<h2 className="mt-2 text-xl font-semibold text-white">النقاط النهائية المستندة إلى Swagger</h2> <h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>النقاط النهائية</h2>
<ul className="mt-6 grid gap-3 text-sm text-slate-200"> <ul style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem", listStyle: "none", padding: 0 }}>
<li className="rounded-2xl border border-white/10 bg-slate-800/80 p-4">/v1/dashboard/summary نظرة عامة تشغيلية</li> {[
<li className="rounded-2xl border border-white/10 bg-slate-800/80 p-4">/v1/client إدارة العملاء</li> "/v1/dashboard/summary — نظرة عامة تشغيلية",
<li className="rounded-2xl border border-white/10 bg-slate-800/80 p-4">/v1/orders دورة شحن الطلبات</li> "/v1/client — إدارة العملاء",
<li className="rounded-2xl border border-white/10 bg-slate-800/80 p-4">/v1/trip تنظيم الرحلات</li> "/v1/orders — دورة شحن الطلبات",
</ul> "/v1/trip — تنظيم الرحلات",
</article> ].map((ep) => (
</section> <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)" }}>
</> {ep}
)} </li>
</section> ))}
</main> </ul>
</article>
</div>
</>
)}
</section>
); );
} }

View File

@@ -1,3 +1,247 @@
export default function DriversPage() { "use client";
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Drivers module is ready for the workforce and report tools.</section>;
import { useEffect, useState } from "react";
import { getStoredToken } from "../../lib/auth";
import { get, post, put, del } from "../../services/api";
import { Spinner, Alert } from "../../Components/UI";
interface Driver {
id: string;
name: string;
userName?: string;
phone: string;
nationality?: string;
licenseNumber?: string;
licenseExpiry?: string;
nationalIdExpiry?: string;
status: "Active" | "Inactive" | "InTrip" | "Suspended";
isActive: boolean;
branch?: { name: string };
createdAt: string;
} }
interface ApiResponse {
data: {
data: Driver[];
pagination: { total: number; page: number; pages: number };
meta?: { total: number; pages: number };
};
}
const STATUS_MAP: Record<Driver["status"], { label: string; color: string; bg: string; border: string }> = {
Active: { label: "نشط", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0" },
InTrip: { label: "في رحلة", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE" },
Inactive: { label: "غير نشط", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0" },
Suspended: { label: "موقوف", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA" },
};
function fmtDate(iso?: string) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric", month: "short", day: "numeric",
});
}
function expirySoon(iso?: string) {
if (!iso) return false;
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
}
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 default function DriversPage() {
const [drivers, setDrivers] = useState<Driver[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
useEffect(() => {
const token = getStoredToken();
setLoading(true);
setError(null);
const query = `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
get<ApiResponse>(`driver${query}`, token)
.then((res) => {
const payload = res.data ?? res;
setDrivers(payload.data ?? []);
setTotal(payload.meta?.total ?? 0);
setPages(payload.meta?.pages ?? 1);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [page, search]);
return (
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* 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)",
}}>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة الكوادر
</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 }}>
السائقون
</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={{ position: "relative", width: "100%", maxWidth: 288 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="text" placeholder="بحث بالاسم أو الهاتف..." value={search}
onChange={(e) => { 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>
</div>
</header>
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
{/* Table */}
<div style={cardStyle}>
<div dir="rtl" style={{
display: "grid",
gridTemplateColumns: "2fr 1.2fr 1fr 1fr 1fr 1fr",
...thStyle,
}}>
<span>السائق</span>
<span>الفرع</span>
<span>الجنسية</span>
<span>انتهاء الرخصة</span>
<span>الحالة</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>
) : drivers.length === 0 ? (
<p style={{ textAlign: "center", padding: "4rem 0", fontSize: 13, color: "var(--color-text-muted)" }}>
لا توجد نتائج {search && `لـ "${search}"`}
</p>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{drivers.map((d, i) => {
const status = STATUS_MAP[d.status];
const licWarn = expirySoon(d.licenseExpiry);
const idWarn = expirySoon(d.nationalIdExpiry);
return (
<li key={d.id} style={{
display: "grid",
gridTemplateColumns: "2fr 1.2fr 1fr 1fr 1fr 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>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
</div>
<span style={{ color: "var(--color-text-secondary)" }}>{d.branch?.name ?? "—"}</span>
<span style={{ color: "var(--color-text-secondary)" }}>{d.nationality ?? "—"}</span>
<span style={{
fontSize: 12, fontWeight: 600,
color: licWarn ? "#D97706" : "var(--color-text-secondary)",
}}>
{licWarn && "⚠ "}{fmtDate(d.licenseExpiry)}
</span>
<span style={{
display: "inline-flex", alignItems: "center",
borderRadius: "var(--radius-full)",
border: `1px solid ${status.border}`,
background: status.bg,
padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 600, color: status.color,
width: "fit-content",
}}>
{status.label}
</span>
<span style={{
textAlign: "center", fontSize: 12, fontWeight: 600,
color: idWarn ? "#D97706" : "var(--color-text-secondary)",
}}>
{idWarn && "⚠ "}{fmtDate(d.nationalIdExpiry)}
</span>
</li>
);
})}
</ul>
)}
{pages > 1 && (
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem",
}}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
{ label: "التالي", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
].map(btn => (
<button key={btn.label} onClick={btn.action} disabled={btn.disabled}
style={{
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
padding: "0.375rem 0.875rem",
fontSize: 12, color: "var(--color-text-secondary)",
cursor: btn.disabled ? "not-allowed" : "pointer",
opacity: btn.disabled ? 0.4 : 1,
fontFamily: "var(--font-sans)",
}}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
</section>
);
}

View File

@@ -1,6 +1,7 @@
@import "../styles/tokens.css"; @import "../styles/tokens.css";
@import "tailwindcss"; @import "tailwindcss";
/* ── Base resets ────────────────────────────────────── */ /* ── Base resets ────────────────────────────────────── */
*, *,
*::before, *::before,
@@ -27,11 +28,60 @@ body {
/* ── Tailwind theme bridge ──────────────────────────── */ /* ── Tailwind theme bridge ──────────────────────────── */
/* Expose CSS variables as Tailwind color utilities. */ /* Expose CSS variables as Tailwind color utilities. */
@theme inline { @theme inline {
--color-background: var(--color-surface-sunken); --color-primary-*: initial;
--color-foreground: var(--color-text-primary); --color-sand-*: initial;
--color-brand: var(--color-brand-600); --color-gold-*: initial;
--font-sans: var(--font-sans); --color-slate-*: initial;
--font-mono: var(--font-mono);
--color-primary-50: var(--color-primary-50);
--color-primary-100: var(--color-primary-100);
--color-primary-200: var(--color-primary-200);
--color-primary-300: var(--color-primary-300);
--color-primary-400: var(--color-primary-400);
--color-primary-500: var(--color-primary-500);
--color-primary-600: var(--color-primary-600);
--color-primary-700: var(--color-primary-700);
--color-primary-800: var(--color-primary-800);
--color-primary-900: var(--color-primary-900);
--color-sand-50: var(--color-sand-50);
--color-sand-100: var(--color-sand-100);
--color-sand-200: var(--color-sand-200);
--color-sand-300: var(--color-sand-300);
--color-sand-400: var(--color-sand-400);
--color-sand-500: var(--color-sand-500);
--color-sand-600: var(--color-sand-600);
--color-sand-700: var(--color-sand-700);
--color-sand-800: var(--color-sand-800);
--color-sand-900: var(--color-sand-900);
--color-gold-50: var(--color-gold-50);
--color-gold-100: var(--color-gold-100);
--color-gold-200: var(--color-gold-200);
--color-gold-300: var(--color-gold-300);
--color-gold-400: var(--color-gold-400);
--color-gold-500: var(--color-gold-500);
--color-gold-600: var(--color-gold-600);
--color-gold-700: var(--color-gold-700);
--color-gold-800: var(--color-gold-800);
--color-gold-900: var(--color-gold-900);
--color-slate-50: var(--color-slate-50);
--color-slate-100: var(--color-slate-100);
--color-slate-200: var(--color-slate-200);
--color-slate-300: var(--color-slate-300);
--color-slate-400: var(--color-slate-400);
--color-slate-500: var(--color-slate-500);
--color-slate-600: var(--color-slate-600);
--color-slate-700: var(--color-slate-700);
--color-slate-800: var(--color-slate-800);
--color-slate-900: var(--color-slate-900);
--color-background: var(--color-sand-50);
--color-foreground: var(--color-slate-900);
--color-brand: var(--color-primary-500);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
} }
/* ── Focus ring utility ─────────────────────────────── */ /* ── Focus ring utility ─────────────────────────────── */

View File

@@ -4,38 +4,32 @@ import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { loginUser } from "../../lib/auth"; import { loginUser } from "../../lib/auth";
import Logo from "@/utils/logo"; import Logo from "../../utils/logo";
import { Spinner } from "@/Components/UI"; import { Button } from "../../Components/UI";
import { Input } from "../../Components/UI";
import { Alert } from "../../Components/UI";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const [identity, setIdentity] = useState(""); const [identity, setIdentity] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); e.preventDefault();
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
await loginUser(identity, password); await loginUser(identity, password);
console.log("Login successful", { identity });
window.location.href = "/dashboard"; // Use full page reload to ensure all client state is reset after login window.location.href = "/dashboard";
} catch (err) { } catch (err) {
// FIX: Surface specific error messages from auth.ts (invalid credentials,
// role denial, network failure) directly to the user.
if (err instanceof Error) { if (err instanceof Error) {
// Network/fetch failure signals const msg = err.message.toLowerCase();
if ( if (msg.includes("fetch") || msg.includes("network") || msg.includes("failed to fetch")) {
err.message.includes("fetch") || setError("Unable to connect. Please check your internet connection and try again later.");
err.message.includes("network") ||
err.message.includes("Failed to fetch")
) {
setError(
"Unable to connect. Please check your internet connection and try again later.",
);
} else { } else {
setError(err.message); setError(err.message);
} }
@@ -50,45 +44,37 @@ export default function LoginPage() {
return ( return (
<main className="min-h-screen bg-slate-100 text-slate-900"> <main className="min-h-screen bg-slate-100 text-slate-900">
<section className="grid min-h-screen w-full lg:grid-cols-[1fr_1fr]"> <section className="grid min-h-screen w-full lg:grid-cols-[1fr_1fr]">
{/* ── Left panel ─────────────────────────────────── */}
<aside className="relative overflow-hidden bg-linear-to-br from-blue-900 via-blue-600 to-blue-700 px-8 py-10 text-white lg:px-12 lg:py-14"> <aside className="relative overflow-hidden bg-linear-to-br from-blue-900 via-blue-600 to-blue-700 px-8 py-10 text-white lg:px-12 lg:py-14">
<div <div
aria-hidden="true"
className="absolute inset-0 opacity-30" className="absolute inset-0 opacity-30"
style={{ style={{
backgroundImage: backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)",
"radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)",
backgroundSize: "40px 40px", backgroundSize: "40px 40px",
}} }}
/> />
<div className="relative z-10 flex h-full flex-col justify-between"> <div className="relative z-10 flex h-full flex-col justify-between">
<div> <div>
<Logo /> <Logo white={true} />
<h1 className="mt-10 max-w-md text-[36px] font-bold leading-tight tracking-[-0.5px]"> <h1 className="mt-10 max-w-md text-[36px] font-bold leading-tight tracking-[-0.5px]">
اللوجستيات ببساطة، والتسليم بثقة. اللوجستيات ببساطة، والتسليم بثقة.
</h1> </h1>
<p className="mt-4 max-w-md text-[15px] leading-7 text-white/75"> <p className="mt-4 max-w-md text-[15px] leading-7 text-white/75">
راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب من خلال تسجيل دخول آمن واحد.
من خلال تسجيل دخول آمن واحد.
</p> </p>
<div className="mt-8 space-y-3"> <div className="mt-8 space-y-3">
{[ {[
[ ["التنسيق اللحظي", "تتبع كل طلب من الاستلام إلى التسليم في مكان واحد."],
"التنسيق اللحظي", ["وصول آمن", "الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة."],
"تتبع كل طلب من الاستلام إلى التسليم في مكان واحد.", ["عمليات سريعة", "استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات."],
],
[
"وصول آمن",
"الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة.",
],
[
"عمليات سريعة",
"استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات.",
],
].map(([title, desc]) => ( ].map(([title, desc]) => (
<article <article
key={title} key={title}
className="flex items-start gap-3 rounded-[10px] border border-white/15 bg-white/10 p-3" className="flex items-start gap-3 rounded-[10px] border border-white/15 bg-white/10 p-3"
> >
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/15 text-sm"> <div aria-hidden="true" className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/15 text-sm">
</div> </div>
<div> <div>
@@ -99,71 +85,61 @@ export default function LoginPage() {
))} ))}
</div> </div>
</div> </div>
<p className="text-[12px] text-white/50"> <p className="text-[12px] text-white/50">مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.</p>
مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.
</p>
</div> </div>
</aside> </aside>
{/* ── Right panel ────────────────────────────────── */}
<section className="flex items-center justify-center bg-slate-50 px-6 py-10 lg:px-8"> <section className="flex items-center justify-center bg-slate-50 px-6 py-10 lg:px-8">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="w-full max-w-100 rounded-[14px] border border-slate-200 bg-white p-8 shadow-sm" className="w-full max-w-[400px] rounded-[14px] border border-slate-200 bg-white p-8 shadow-sm"
noValidate
> >
<p className="text-[28px] font-bold text-slate-900"> <p className="text-[28px] font-bold text-slate-900">مرحبًا بعودتك</p>
مرحبًا بعودتك <p className="mt-1 text-[14px] text-slate-500">سجل الدخول لإدارة عمليات التسليم</p>
</p>
<p className="mt-1 text-[14px] text-slate-500">
سجل الدخول لإدارة عمليات التسليم
</p>
<label className="mt-6 block text-[11px] font-bold uppercase tracking-[0.5px] text-slate-500"> <div className="mt-6 flex flex-col gap-4">
البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم <Input
<input label="البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم"
autoComplete="username" autoComplete="username"
className="mt-2 h-11 w-full rounded-[9px] border border-slate-200 bg-white px-3 text-[14px] text-slate-900 outline-none transition focus:border-blue-500 focus:ring-4 focus:ring-blue-100"
value={identity} value={identity}
onChange={(event) => setIdentity(event.target.value)} onChange={(e) => setIdentity(e.target.value)}
placeholder="name@company.com / 05xxxxxxxx / username" placeholder="name@company.com / 05xxxxxxxx / username"
required required
/> />
</label>
<label className="mt-4 block text-[11px] font-bold uppercase tracking-[0.5px] text-slate-500"> <Input
كلمة المرور label="كلمة المرور"
<input
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
className="mt-2 h-11 w-full rounded-[9px] border border-slate-200 bg-white px-3 text-[14px] text-slate-900 outline-none transition focus:border-blue-500 focus:ring-4 focus:ring-blue-100"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
required required
/> />
</label> </div>
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<Link <Link href="/forgot-password" className="text-[12px] text-blue-600 hover:underline">
href="/forgot-password"
className="text-[12px] text-blue-600 hover:underline"
>
هل نسيت كلمة المرور؟ هل نسيت كلمة المرور؟
</Link> </Link>
</div> </div>
{error ? ( {error && (
<p className="mt-4 rounded-[9px] border border-red-200 bg-red-50 p-3 text-[13px] text-red-600"> <div className="mt-4">
{error} <Alert type="error" message={error} onClose={() => setError(null)} />
</p> </div>
) : null} )}
<button <Button
type="submit" type="submit"
disabled={loading} loading={loading}
className="mt-6 inline-flex h-11.5 w-full items-center justify-center rounded-[9px] bg-blue-600 text-[15px] font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-70" fullWidth
className="mt-6 h-11"
> >
{loading ? <Spinner /> : "تسجيل الدخول"} {loading ? "جاري تسجيل الدخول…" : "تسجيل الدخول"}
</button> </Button>
<p className="mt-4 text-center text-[13px] text-slate-500"> <p className="mt-4 text-center text-[13px] text-slate-500">
ليس لديك حساب؟{" "} ليس لديك حساب؟{" "}
@@ -176,4 +152,4 @@ export default function LoginPage() {
</section> </section>
</main> </main>
); );
} }

View File

@@ -1,3 +1,281 @@
export default function OrdersPage() { "use client";
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Orders module is ready for the order lifecycle and transfer workflows.</section>;
import { useEffect, useState } from "react";
import { getStoredToken } from "../../lib/auth";
import { get } from "../../services/api";
import { Spinner, Alert } from "../../Components/UI";
interface Order {
id: string;
shipmentNumber: string;
recipientName: string;
recipientPhone: string;
currentStatus: "Created" | "Assigned" | "InTransit" | "Delivered" | "Returned" | "Cancelled";
totalPrice?: number;
paymentMethod?: string;
paymentStatus?: "Pending" | "Paid" | "Failed" | "Refunded";
client?: { name: string };
trip?: { tripNumber: string };
createdAt: string;
} }
interface ApiResponse {
data: {
data: Order[];
pagination: { total: number; page: number; pages: number };
meta?: { total: number; pages: number };
};
}
const STATUS_MAP: Record<Order["currentStatus"], { label: string; color: string; bg: string; border: string }> = {
Created: { label: "تم الإنشاء", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE" },
Assigned: { label: "مُعيَّن", color: "#5B21B6", bg: "#F5F3FF", border: "#DDD6FE" },
InTransit: { label: "قيد التوصيل", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A" },
Delivered: { label: "تم التسليم", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0" },
Returned: { label: "مُرتجع", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA" },
Cancelled: { label: "ملغي", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0" },
};
const PAY_MAP: Record<string, { label: string; color: string }> = {
Pending: { label: "معلَّق", color: "#D97706" },
Paid: { label: "مدفوع", color: "#16A34A" },
Failed: { label: "فشل", color: "#DC2626" },
Refunded: { label: "مُسترجع", color: "#64748B" },
};
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 default function OrdersPage() {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>("");
useEffect(() => {
const token = getStoredToken();
setLoading(true);
setError(null);
const params = new URLSearchParams({ page: String(page), limit: "10" });
if (search) params.set("search", search);
if (statusFilter) params.set("currentStatus", statusFilter);
get<ApiResponse>(`orders?${params}`, token)
.then((res) => {
const payload = res.data ?? res;
setOrders(payload.data ?? []);
setTotal(payload.meta?.total ?? 0);
setPages(payload.meta?.pages ?? 1);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [page, search, statusFilter]);
return (
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* 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)",
}}>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة الشحنات
</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 }}>
الطلبات
</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" }}>
{/* Status filter */}
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
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)",
}}
>
<option value="">كل الحالات</option>
{Object.entries(STATUS_MAP).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
{/* Search */}
<div style={{ position: "relative", width: 256 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="text" placeholder="رقم الشحنة أو المستلم..." value={search}
onChange={(e) => { 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>
</div>
</div>
</header>
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
{/* Table */}
<div style={cardStyle}>
<div dir="rtl" style={{
display: "grid",
gridTemplateColumns: "1.8fr 1.5fr 1.2fr 1fr 1fr 1fr",
...thStyle,
}}>
<span>رقم الشحنة</span>
<span>المستلم</span>
<span>العميل</span>
<span>المبلغ</span>
<span>الحالة</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>
) : orders.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 }}>
{orders.map((o, i) => {
const status = STATUS_MAP[o.currentStatus];
const pay = o.paymentStatus ? PAY_MAP[o.paymentStatus] : null;
return (
<li key={o.id} style={{
display: "grid",
gridTemplateColumns: "1.8fr 1.5fr 1.2fr 1fr 1fr 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>
<p style={{ fontFamily: "var(--font-mono)", fontSize: 12, fontWeight: 700, color: "#2563EB", margin: 0 }}>
{o.shipmentNumber}
</p>
{o.trip && (
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>
رحلة: {o.trip.tripNumber}
</p>
)}
</div>
<div>
<p style={{ color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
{o.recipientPhone}
</p>
</div>
<span style={{ color: "var(--color-text-secondary)" }}>{o.client?.name ?? "—"}</span>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
{o.totalPrice != null ? `${o.totalPrice.toFixed(2)} ر.س` : "—"}
</p>
{pay && (
<p style={{ marginTop: 2, fontSize: 11, fontWeight: 600, color: pay.color }}>
{pay.label}
</p>
)}
</div>
<span style={{
display: "inline-flex", alignItems: "center",
borderRadius: "var(--radius-full)",
border: `1px solid ${status.border}`,
background: status.bg,
padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 600, color: status.color,
width: "fit-content",
}}>
{status.label}
</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(o.createdAt).toLocaleDateString("ar-SA", {
year: "numeric", month: "short", day: "numeric",
})}
</span>
</li>
);
})}
</ul>
)}
{pages > 1 && (
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem",
}}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
{ label: "التالي", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
].map(btn => (
<button key={btn.label} onClick={btn.action} disabled={btn.disabled}
style={{
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
padding: "0.375rem 0.875rem",
fontSize: 12, color: "var(--color-text-secondary)",
cursor: btn.disabled ? "not-allowed" : "pointer",
opacity: btn.disabled ? 0.4 : 1,
fontFamily: "var(--font-sans)",
}}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
</section>
);
}

View File

@@ -1,3 +1,137 @@
// صفحة إدارة المستخدمين — تستورد فقط من الطبقات الأخرى
"use client";
import { useState } from "react";
import { Alert } from "../../Components/UI";
import { UserTable } from "../../Components/User/UserTable";
import { UserFormModal } from "../../Components/User/UserFormModal";
import { DeleteConfirmModal } from "../../Components/User/DeleteConfirmModal";
import { Toast } from "../../Components/User/Toast";
import { useUsers } from "../../hooks/useUser";
import type { User, UserFormData } from "../../types/user";
export default function UsersPage() { export default function UsersPage() {
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Users module is ready for the full CRUD UI.</section>; // ── الحالة المحلية للمودالات فقط ─────────────────────────────────────────
} // false = مغلق، null = وضع الإضافة، User = وضع التعديل
const [formTarget, setFormTarget] = useState<User | null | false>(false);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [deleting, setDeleting] = useState(false);
// ── كل منطق البيانات والحالة من الهوك ────────────────────────────────────
const {
users, loading, total, pages, error,
roles, branches,
page, search,
setPage, handleSearch, clearError,
createUser, updateUser, deleteUser,
notification,
} = useUsers();
// ── معالج تقديم النموذج (إضافة أو تعديل) ────────────────────────────────
const handleFormSubmit = async (data: UserFormData, isNew: boolean): Promise<boolean> => {
if (isNew) return createUser(data);
return updateUser((formTarget as User).id, data);
};
// ── معالج تأكيد الحذف ────────────────────────────────────────────────────
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
setDeleting(true);
const ok = await deleteUser(deleteTarget.id);
setDeleting(false);
if (ok) setDeleteTarget(null);
};
return (
<>
{/* ── Toast لعرض رسائل النجاح والخطأ ── */}
<Toast notification={notification} />
{/* ── مودال الإضافة / التعديل ── */}
{formTarget !== false && (
<UserFormModal
editUser={formTarget}
roles={roles}
branches={branches}
onClose={() => setFormTarget(false)}
onSubmit={handleFormSubmit}
/>
)}
{/* ── مودال تأكيد الحذف ── */}
{deleteTarget && (
<DeleteConfirmModal
user={deleteTarget}
deleting={deleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
/>
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── رأس الصفحة ── */}
<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 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 }}>
المستخدمون
</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" }}>
{/* حقل البحث */}
<div style={{ position: "relative", width: 256 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input type="text" placeholder="بحث بالاسم أو الهاتف..." value={search}
onChange={e => handleSearch(e.target.value)} dir="rtl"
style={{ width: "100%", height: 40, paddingRight: 36, paddingLeft: 12, borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, outline: "none", fontFamily: "var(--font-sans)" }}
/>
</div>
{/* زر إضافة مستخدم */}
<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" }}>
<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>
</div>
</div>
</header>
{/* ── رسالة الخطأ العامة (فشل التحميل) ── */}
{error && (
<Alert type="error" message={error} onClose={clearError} />
)}
{/* ── جدول المستخدمين ── */}
<UserTable
users={users}
loading={loading}
search={search}
page={page}
pages={pages}
onEdit={user => setFormTarget(user)}
onDelete={user => setDeleteTarget(user)}
onAddFirst={() => setFormTarget(null)}
onPageChange={setPage}
/>
</section>
</>
);
}

181
hooks/useOrder.ts Normal file
View File

@@ -0,0 +1,181 @@
"use client";
import { useCallback, useEffect, useReducer } from "react";
import { orderService } from "../services/order.service";
import type {
Order,
CreateOrderPayload,
UpdateOrderPayload,
UpdateOrderStatusPayload,
} from "../types/order";
// ── State ─────────────────────────────────────────────────
interface State {
orders: Order[];
total: number;
page: number;
totalPages: number;
loading: boolean;
submitting: boolean;
error: string | null;
}
type Action =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; payload: { orders: Order[]; total: number; totalPages: number; page: number } }
| { type: "FETCH_ERROR"; error: string }
| { type: "SUBMIT_START" }
| { type: "SUBMIT_SUCCESS"; order: Order; isNew: boolean }
| { type: "SUBMIT_ERROR"; error: string }
| { type: "DELETE_SUCCESS"; id: string }
| { type: "CLEAR_ERROR" };
const initialState: State = {
orders: [],
total: 0,
page: 1,
totalPages: 1,
loading: false,
submitting: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "FETCH_START":
return { ...state, loading: true, error: null };
case "FETCH_SUCCESS":
return {
...state,
loading: false,
orders: action.payload.orders,
total: action.payload.total,
totalPages: action.payload.totalPages,
page: action.payload.page,
};
case "FETCH_ERROR":
return { ...state, loading: false, error: action.error };
case "SUBMIT_START":
return { ...state, submitting: true, error: null };
case "SUBMIT_SUCCESS":
return {
...state,
submitting: false,
orders: action.isNew
? [action.order, ...state.orders]
: state.orders.map((o) => (o.id === action.order.id ? action.order : o)),
};
case "SUBMIT_ERROR":
return { ...state, submitting: false, error: action.error };
case "DELETE_SUCCESS":
return { ...state, orders: state.orders.filter((o) => o.id !== action.id) };
case "CLEAR_ERROR":
return { ...state, error: null };
default:
return state;
}
}
// ── Hook ──────────────────────────────────────────────────
export function useOrders(autoFetch = true) {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchOrders = useCallback(
async (page = 1, limit = 20) => {
dispatch({ type: "FETCH_START" });
try {
const res = await orderService.getAll({ page, limit });
const body = (res as unknown as { data: { data: Order[]; meta: { total: number; totalPages: number; page: number } } }).data;
dispatch({
type: "FETCH_SUCCESS",
payload: {
orders: body.data,
total: body.meta.total,
totalPages: body.meta.totalPages,
page: body.meta.page,
},
});
} catch (err: unknown) {
dispatch({
type: "FETCH_ERROR",
error: err instanceof Error ? err.message : "Failed to load orders",
});
}
},
[],
);
const createOrder = useCallback(async (payload: CreateOrderPayload) => {
dispatch({ type: "SUBMIT_START" });
try {
const res = await orderService.create(payload);
const order = (res as unknown as { data: Order }).data;
dispatch({ type: "SUBMIT_SUCCESS", order, isNew: true });
return order;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to create order";
dispatch({ type: "SUBMIT_ERROR", error: msg });
throw new Error(msg);
}
}, []);
const updateOrder = useCallback(
async (id: string, payload: UpdateOrderPayload) => {
dispatch({ type: "SUBMIT_START" });
try {
const res = await orderService.update(id, payload);
const order = (res as unknown as { data: Order }).data;
dispatch({ type: "SUBMIT_SUCCESS", order, isNew: false });
return order;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to update order";
dispatch({ type: "SUBMIT_ERROR", error: msg });
throw new Error(msg);
}
},
[],
);
const updateStatus = useCallback(
async (id: string, payload: UpdateOrderStatusPayload) => {
dispatch({ type: "SUBMIT_START" });
try {
const res = await orderService.updateStatus(id, payload);
const order = (res as unknown as { data: Order }).data;
dispatch({ type: "SUBMIT_SUCCESS", order, isNew: false });
return order;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to update status";
dispatch({ type: "SUBMIT_ERROR", error: msg });
throw new Error(msg);
}
},
[],
);
const deleteOrder = useCallback(async (id: string) => {
try {
await orderService.delete(id);
dispatch({ type: "DELETE_SUCCESS", id });
} catch (err: unknown) {
dispatch({
type: "FETCH_ERROR",
error: err instanceof Error ? err.message : "Failed to delete order",
});
}
}, []);
useEffect(() => {
if (autoFetch) fetchOrders();
}, [autoFetch, fetchOrders]);
return {
...state,
fetchOrders,
createOrder,
updateOrder,
updateStatus,
deleteOrder,
clearError: () => dispatch({ type: "CLEAR_ERROR" }),
};
}

166
hooks/useUser.ts Normal file
View File

@@ -0,0 +1,166 @@
"use client";
import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "../lib/auth";
import { userService } from "../services/user.service";
import type { Branch } from "../types/branch";
import type { Role } from "../types/role";
import type {
TableAction,
TableState,
User,
UserFormData,
} from "../types/user";
// ── message taype─────────────────────────────────────────
export interface Notification {
type: "success" | "error";
message: string;
}
// ── Reducer ──────────────────────────────────────
function tableReducer(s: TableState, a: TableAction): TableState {
switch (a.type) {
case "LOAD_START": return { ...s, loading: true, error: null };
case "LOAD_OK": return { ...s, loading: false, users: a.users, total: a.total, pages: a.pages };
case "LOAD_ERR": return { ...s, loading: false, error: a.error };
case "ADD": return { ...s, users: [a.user, ...s.users] };
case "UPDATE": return { ...s, users: s.users.map(u => u.id === a.user.id ? a.user : u) };
case "DELETE": return { ...s, users: s.users.filter(u => u.id !== a.id) };
case "CLEAR_ERR": return { ...s, error: null };
default: return s;
}
}
// ── first status ─────────────────────────────────────────────────────────
const initialState: TableState = {
users: [], loading: true, total: 0, pages: 1, error: null,
};
// ── main hook ──────────────────────────────────────────────────────────────
export function useUsers() {
const [state, dispatch] = useReducer(tableReducer, initialState);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [roles, setRoles] = useState<Role[]>([]);
const [branches, setBranches] = useState<Branch[]>([]);
const [notification, setNotification] = useState<Notification | null>(null);
// notify with auto-dismiss after 4s
const notify = useCallback((n: Notification) => {
setNotification(n);
setTimeout(() => setNotification(null), 4000);
}, []);
// fetch users with pagination and search
const loadUsers = useCallback(async (p: number, q: string) => {
dispatch({ type: "LOAD_START" });
try {
const token = getStoredToken();
const res = await userService.getAll(p, q, token);
const payload = res.data ?? res;
dispatch({
type: "LOAD_OK",
users: payload.data ?? [],
total: payload.meta?.total ?? 0,
pages: payload.meta?.pages ?? 1,
});
} catch {
dispatch({ type: "LOAD_ERR", error: "عذراً، حدث خطأ أثناء تحميل بيانات المستخدمين. يرجى المحاولة لاحقاً." });
}
}, []);
// fetch roles and branches for form dropdowns on mount
useEffect(() => {
const token = getStoredToken();
userService.getRoles(token)
.then(res => setRoles((res.data ?? res).data ?? []))
.catch(() => {});
userService.getBranches(token)
.then(res => setBranches((res.data ?? res).data ?? []))
.catch(() => {});
}, []);
// reload users when page or search changes
useEffect(() => {
loadUsers(page, search);
}, [page, search, loadUsers]);
// create new user
const createUser = useCallback(async (data: UserFormData): Promise<boolean> => {
try {
const token = getStoredToken();
const res = await userService.create(data as UserFormData & { password: string }, token);
dispatch({ type: "ADD", user: res.data });
notify({ type: "success", message: "تم إنشاء مستخدم جديد بنجاح." });
return true;
} catch (err) {
const msg = err instanceof Error && err.message
? err.message
: "تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.";
notify({ type: "error", message: msg });
return false;
}
}, [notify]);
// update existing user
const updateUser = useCallback(async (id: string, data: UserFormData): Promise<boolean> => {
try {
const token = getStoredToken();
const res = await userService.update(id, data, token);
dispatch({ type: "UPDATE", user: res.data });
notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." });
return true;
} catch (err) {
const msg = err instanceof Error && err.message
? err.message
: "تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.";
notify({ type: "error", message: msg });
return false;
}
}, [notify]);
// deletwe user
const deleteUser = useCallback(async (id: string): Promise<boolean> => {
try {
const token = getStoredToken();
await userService.delete(id, token);
dispatch({ type: "DELETE", id });
notify({ type: "success", message: "تم حذف المستخدم بنجاح." });
return true;
} catch (err) {
const msg = err instanceof Error && err.message
? err.message
: "تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.";
notify({ type: "error", message: msg });
return false;
}
}, [notify]);
const handleSearch = useCallback((q: string) => {
setSearch(q);
setPage(1);
}, []);
const clearError = useCallback(() => dispatch({ type: "CLEAR_ERR" }), []);
return {
//table data and status
...state,
// dropdown data for forms
roles,
branches,
// pagination and search state
page,
search,
setPage,
handleSearch,
clearError,
//main actions
createUser,
updateUser,
deleteUser,
// notifications for success/error messages
notification,
};
}

View File

@@ -1,13 +1,14 @@
import { requestJson } from "./api"; import { authService } from "@/services/auth.service";
import type { AuthUser } from "@/types/auth";
const AUTH_TOKEN_KEY = "auth_token"; const AUTH_TOKEN_KEY = "auth_token";
const AUTH_USER_KEY = "auth_user"; const AUTH_USER_KEY = "auth_user";
const AUTH_TOKEN_COOKIE = "auth_token"; // cookie name must match what the server expects (e.g., in middleware) const AUTH_TOKEN_COOKIE = "auth_token";
// ─── Cookie helpers ─────────────────────────── // ─── Cookie helpers ───────────────────────────
function setTokenCookie(token: string) { function setTokenCookie(token: string) {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
const exp = new Date(Date.now() + 7 * 864e5).toUTCString(); // 7 أيام const exp = new Date(Date.now() + 7 * 864e5).toUTCString();
document.cookie = `${AUTH_TOKEN_COOKIE}=${encodeURIComponent(token)}; expires=${exp}; path=/; SameSite=Lax`; document.cookie = `${AUTH_TOKEN_COOKIE}=${encodeURIComponent(token)}; expires=${exp}; path=/; SameSite=Lax`;
} }
@@ -16,25 +17,7 @@ function deleteTokenCookie() {
document.cookie = `${AUTH_TOKEN_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`; document.cookie = `${AUTH_TOKEN_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
} }
// ─── Types ─────────────────────────────────── // ─── Storage ──────────────────────────────────
export interface AuthUser {
id?: string;
name?: string;
email?: string;
role?: string;
permissions?: string[];
roleId?: string;
userName?: string;
phone?: string;
[key: string]: unknown;
}
export interface LoginResponse {
token: string;
user: AuthUser;
}
// ─── Storage ─────────────────────────────────
export function getStoredToken(): string | null { export function getStoredToken(): string | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
return localStorage.getItem(AUTH_TOKEN_KEY); return localStorage.getItem(AUTH_TOKEN_KEY);
@@ -44,19 +27,13 @@ export function getStoredUser(): AuthUser | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
const raw = localStorage.getItem(AUTH_USER_KEY); const raw = localStorage.getItem(AUTH_USER_KEY);
if (!raw) return null; if (!raw) return null;
try { try { return JSON.parse(raw) as AuthUser; } catch { return null; }
return JSON.parse(raw) as AuthUser;
} catch {
return null;
}
} }
export function saveAuth(token: string, user: AuthUser) { export function saveAuth(token: string, user: AuthUser) {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
//save in localStorage for client-side access
localStorage.setItem(AUTH_TOKEN_KEY, token); localStorage.setItem(AUTH_TOKEN_KEY, token);
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user)); localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user));
// save in cookie for server-side access (e.g., in middleware)
setTokenCookie(token); setTokenCookie(token);
} }
@@ -67,72 +44,44 @@ export function clearAuth() {
deleteTokenCookie(); deleteTokenCookie();
} }
// ─── Blocked Roles ──────────────────────────── // ─── Blocked roles ────────────────────────────
const BLOCKED_ROLES = ["driver", "سائق"] as const; const BLOCKED_ROLES = ["driver", "سائق"] as const;
type RawLoginPayload = {
success?: boolean;
message?: string;
data?: { token?: string; user?: AuthUser };
token?: string;
user?: AuthUser;
};
type RawUser = {
role?: {
name?: string;
permissions?: Array<{ permission?: { slug?: string } }>;
} | string;
};
// ─── Login ──────────────────────────────────── // ─── Login ────────────────────────────────────
export async function loginUser( type RawRole = { name?: string; permissions?: Array<{ permission?: { slug?: string } }> } | string;
identity: string,
password: string,
): Promise<LoginResponse> {
const payload = await requestJson<RawLoginPayload>("/auth/login", {
method: "POST",
body: JSON.stringify({ identity, password }),
});
console.log("Login response payload:", payload);
const token = payload.data?.token ?? payload.token; export async function loginUser(identity: string, password: string) {
const user = payload.data?.user ?? payload.user; const payload = await authService.login({ identity, password });
const { token, user } = payload.data || {};
console.log({ token, user } );
if (!token || !user) { if (!token || !user) {
throw new Error("اسم المستخدم أو كلمة المرور غير صحيحة."); throw new Error("اسم المستخدم أو كلمة المرور غير صحيحة.");
} }
// destracture role from user and determine roleName const rawRole = (user as unknown as { role?: RawRole }).role;
const rawRole = (user as unknown as RawUser).role; const roleName = typeof rawRole === "string"
const roleName: string | undefined = ? rawRole
typeof rawRole === "string" : typeof rawRole === "object" && rawRole !== null
? rawRole ? rawRole.name
: typeof rawRole === "object" && rawRole !== null : undefined;
? rawRole.name
: undefined;
// reject if role is in blocked list
if (roleName && (BLOCKED_ROLES as readonly string[]).includes(roleName)) { if (roleName && (BLOCKED_ROLES as readonly string[]).includes(roleName)) {
throw new Error("غير مصرح لك بالوصول إلى هذه اللوحة."); throw new Error("غير مصرح لك بالوصول إلى هذه اللوحة.");
} }
//fetch permissions
const rolePermissions = const rolePermissions =
typeof rawRole === "object" && rawRole !== null typeof rawRole === "object" && rawRole !== null
? rawRole.permissions ?? [] ? (rawRole as Exclude<RawRole, string>).permissions ?? []
: []; : [];
const permissions = Array.isArray(rolePermissions) const permissions = Array.isArray(rolePermissions)
? rolePermissions ? rolePermissions
.map((entry) => entry?.permission?.slug) .map(e => e?.permission?.slug)
.filter((slug): slug is string => Boolean(slug)) .filter((s): s is string => Boolean(s))
: []; : [];
const fullUser: AuthUser = { ...user, role: roleName, permissions }; const fullUser: AuthUser = { ...user, role: roleName, permissions };
// save auth data in both localStorage and cookie for client and server access
saveAuth(token, fullUser); saveAuth(token, fullUser);
return { token, user: fullUser }; return { token, user: fullUser };
} }

View File

@@ -1,6 +1,3 @@
// All authentication API calls in one place.
import { post } from "./api"; import { post } from "./api";
import type { LoginResponse } from "../types/auth"; import type { LoginResponse } from "../types/auth";

2
services/car.service.ts Normal file
View File

@@ -0,0 +1,2 @@
export const carService = {};

View File

@@ -0,0 +1 @@
export const driverService = {};

View File

@@ -1,4 +1,7 @@
export { request, get, post, put, del, ApiError } from "./api"; export { request, get, post, put, del, ApiError } from "./api";
export { authService } from "./auth.service"; export { authService } from "./auth.service";
export { dashboardService } from "./dashboard.service"; export { dashboardService } from "./dashboard.service";
export { clientService } from "./client.service"; export { clientService } from "./client.service";
export {userService} from "./user.service";
export { carService } from "./car.service";
export { driverService } from "./driver.service";

45
services/order.service.ts Normal file
View File

@@ -0,0 +1,45 @@
// services/order.service.ts
import { get, post, put, del } from "./api";
import type {
Order,
CreateOrderPayload,
UpdateOrderPayload,
UpdateOrderStatusPayload,
TransferOrderPayload,
OrdersResponse,
} from "../types/order";
export const orderService = {
/** Get all orders (paginated) */
getAll: (params?: Record<string, string | number>) => {
const qs = params
? "?" + new URLSearchParams(params as Record<string, string>).toString()
: "";
return get<OrdersResponse>(`orders${qs}`);
},
/** Get order by ID */
getById: (id: string) => get<Order>(`orders/${id}`),
/** Create new order */
create: (payload: CreateOrderPayload) =>
post<Order>("orders", payload),
/** Update order metadata */
update: (id: string, payload: UpdateOrderPayload) =>
put<Order>(`orders/${id}`, payload),
/** Update order status with optional reason */
updateStatus: (id: string, payload: UpdateOrderStatusPayload) =>
put<Order>(`orders/${id}/status`, payload),
/** Transfer order to a different trip */
transfer: (id: string, payload: TransferOrderPayload) =>
put<Order>(`orders/${id}/transfer`, payload),
/** Soft-delete an order */
delete: (id: string) => del<void>(`orders/${id}`),
/** Get archived orders */
getArchived: () => get<OrdersResponse>("orders/archived"),
};

55
services/user.service.ts Normal file
View File

@@ -0,0 +1,55 @@
// خدمة المستخدمين — كل نداءات الـ API الخاصة بالمستخدمين تكون هنا فقط
import { get, post, put, del } from "./api";
import type { ApiListResponse, User, UserFormData } from "../types/user";
import type { Role } from "../types/role";
import type { Branch } from "../types/branch";
/** نوع الاستجابة لعملية واحدة على مستخدم */
export interface UserResponse {
data: User;
}
/** بناء query string لجلب المستخدمين مع البحث والصفحات */
function buildUsersQuery(page: number, search: string): string {
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
}
export const userService = {
/** جلب قائمة المستخدمين مع إمكانية البحث والتصفح بين الصفحات */
getAll: (page: number, search: string, token: string | null) =>
get<ApiListResponse<User>>(`users${buildUsersQuery(page, search)}`, token),
/** إنشاء مستخدم جديد */
create: (data: Omit<UserFormData, "password"> & { password: string }, token: string | null) =>
post<UserResponse>("users", buildPayload(data, true), token),
/** تعديل بيانات مستخدم موجود */
update: (id: string, data: UserFormData, token: string | null) =>
put<UserResponse>(`users/${id}`, buildPayload(data, false), token),
/** حذف مستخدم */
delete: (id: string, token: string | null) =>
del<void>(`users/${id}`, token),
/** جلب قائمة الأدوار لاستخدامها في نموذج المستخدم */
getRoles: (token: string | null) =>
get<{ data: { data: Role[] } }>("role?limit=100", token),
/** جلب قائمة الفروع لاستخدامها في نموذج المستخدم */
getBranches: (token: string | null) =>
get<{ data: { data: Branch[] } }>("branches?limit=100", token),
};
/** تحويل بيانات النموذج إلى payload مناسب للـ API */
function buildPayload(data: UserFormData, isNew: boolean): Record<string, string> {
const payload: Record<string, string> = {
name: data.name.trim(),
phone: data.phone.trim(),
roleId: data.roleId,
branchId: data.branchId,
};
if (data.email) payload.email = data.email.trim();
if (isNew && data.password) payload.password = data.password;
else if (!isNew && data.password) payload.password = data.password;
return payload;
}

View File

@@ -1,72 +1,96 @@
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
:root { :root {
/* ── Brand palette ─────────────────────────────────── */ /* ── Brand blue — مأخوذ من login مباشرة ── */
--color-brand-50: #EBF3FF; --color-brand-50: #EFF6FF;
--color-brand-100: #BFDBFE; --color-brand-100: #DBEAFE;
--color-brand-600: #1A73E8; --color-brand-200: #BFDBFE;
--color-brand-700: #1557B0; --color-brand-300: #93C5FD;
--color-brand-900: #0D47A1; --color-brand-400: #60A5FA;
--color-brand-500: #3B82F6;
--color-brand-600: #2563EB;
--color-brand-700: #1D4ED8;
--color-brand-800: #1E3A8A;
--color-brand-900: #172554;
/* ── Semantic / status ─────────────────────────────── */ /* ── Sidebar gradient stops ── */
--color-success: #34A853; --sidebar-grad-top: #1E3A8A;
--color-success-light: #D1FAE5; --sidebar-grad-mid: #1D4ED8;
--color-warning: #F59E0B; --sidebar-grad-bottom: #1565C0;
--color-warning-light: #FEF3C7;
--color-danger: #E53935;
--color-danger-light: #FEE2E2;
--color-info: #1A73E8;
--color-info-light: #EBF3FF;
/* ── Surface (light mode) ──────────────────────────── */ /* ── Slate — نفس login ── */
--color-slate-50: #F8FAFC;
--color-slate-100: #F1F5F9;
--color-slate-200: #E2E8F0;
--color-slate-300: #CBD5E1;
--color-slate-400: #94A3B8;
--color-slate-500: #64748B;
--color-slate-600: #475569;
--color-slate-700: #334155;
--color-slate-800: #1E293B;
--color-slate-900: #0F172A;
/* ── Status colors ── */
--color-success: #16A34A;
--color-success-light: #DCFCE7;
--color-success-border:#BBF7D0;
--color-warning: #D97706;
--color-warning-light: #FFFBEB;
--color-warning-border:#FDE68A;
--color-danger: #DC2626;
--color-danger-light: #FEF2F2;
--color-danger-border: #FECACA;
--color-info: #2563EB;
--color-info-light: #EFF6FF;
--color-info-border: #BFDBFE;
/* ── Surfaces — light كامل زي login ── */
--color-surface: #FFFFFF; --color-surface: #FFFFFF;
--color-surface-muted: #F9FAFB; --color-surface-muted: var(--color-slate-50);
--color-surface-sunken: #ECEEF2; --color-surface-sunken: var(--color-slate-100);
--color-border: #E5E7EB; --color-border: var(--color-slate-200);
--color-border-strong: #D1D5DB; --color-border-strong: var(--color-slate-300);
/* ── Text (light mode) ─────────────────────────────── */ /* ── Text ── */
--color-text-primary: #111827; --color-text-primary: var(--color-slate-900);
--color-text-secondary:#374151; --color-text-secondary: var(--color-slate-600);
--color-text-muted: #6B7280; --color-text-muted: var(--color-slate-500);
--color-text-hint: #9CA3AF; --color-text-hint: var(--color-slate-400);
--color-text-inverse: #FFFFFF; --color-text-inverse: #FFFFFF;
/* ── Surface (dark — admin dashboard) ─────────────── */ /* ── للـ dashboard pages — light بدل dark ── */
--color-surface-dark: #020617; /* slate-950 */ --color-surface-dark: var(--color-slate-50);
--color-surface-dark-raised: #0F172A; /* slate-900 */ --color-surface-dark-raised: var(--color-surface);
--color-surface-dark-card: #1E293B; /* slate-800 */ --color-surface-dark-card: var(--color-surface);
--color-border-dark: rgba(255, 255, 255, 0.10); --color-border-dark: var(--color-slate-200);
--color-text-dark-primary: #F1F5F9; /* slate-100 */ --color-text-dark-primary: var(--color-slate-900);
--color-text-dark-muted: #94A3B8; /* slate-400 */ --color-text-dark-muted: var(--color-slate-500);
/* ── Typography ────────────────────────────────────── */ /* ── Typography ── */
--font-sans: 'Plus Jakarta Sans', sans-serif; --font-sans: 'Plus Jakarta Sans', sans-serif;
--font-mono: 'IBM Plex Mono', monospace; --font-mono: 'IBM Plex Mono', monospace;
/* ── Radius ────────────────────────────────────────── */ /* ── Radius ── */
--radius-sm: 7px; --radius-sm: 6px;
--radius-md: 10px; --radius-md: 8px;
--radius-lg: 14px; --radius-lg: 12px;
--radius-xl: 20px; --radius-xl: 16px;
--radius-2xl: 24px; --radius-2xl: 20px;
--radius-full: 9999px; --radius-full: 9999px;
/* ── Spacing / layout ──────────────────────────────── */ /* ── Layout ── */
--sidebar-width: 280px; --sidebar-width: 240px;
--topbar-height: 56px; --topbar-height: 56px;
--page-max-width: 1280px; --page-padding-x: 24px;
--page-padding-x: 24px;
/* ── Shadows ───────────────────────────────────────── */ /* ── Shadows ── */
--shadow-card: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04); --shadow-card: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
--shadow-overlay: 0 20px 60px rgba(0,0,0,.25); --shadow-overlay: 0 10px 40px rgba(0,0,0,.1);
/* ── Transitions ───────────────────────────────────── */ /* ── Transitions ── */
--transition-base: 150ms cubic-bezier(0.4, 0, 0.2, 1); --transition-base: 150ms cubic-bezier(0.4, 0, 0.2, 1);
} }
/* ── Reduced-motion: disable spinner animation ──────── */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.animate-spin { .animate-spin { animation: none; }
animation: none;
}
} }

4
types/branch.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface Branch {
id: string;
name: string;
}

97
types/order.ts Normal file
View File

@@ -0,0 +1,97 @@
export type OrderStatus =
| "Created"
| "Assigned"
| "InTransit"
| "Delivered"
| "Returned"
| "Cancelled";
export type PaymentMethod = "Cash" | "Card" | "Prepaid";
export type PaymentStatus = "Pending" | "Paid" | "Failed" | "Refunded";
export interface OrderAddress {
details?: {
city?: string;
district?: string;
street?: string;
buildingNo?: string;
unitNo?: string;
zipCode?: string;
};
location?: {
coordinates?: [number, number];
};
}
export interface Order {
id: string;
shipmentNumber: string;
recipientName: string;
recipientPhone: string;
currentStatus: OrderStatus;
clientId: string;
tripId?: string | null;
totalPrice?: number | null;
subTotal?: number | null;
vatAmount?: number | null;
vatRate?: number | null;
paymentMethod?: PaymentMethod | null;
paymentStatus?: PaymentStatus | null;
type?: string | null;
quantity: number;
weight?: number | null;
deliveryAddressId?: string | null;
pickupAddressId?: string | null;
createdAt: string;
updatedAt: string;
statusHistory?: Array<{
id: string;
status: OrderStatus;
reason?: string | null;
createdAt: string;
}>;
client?: { id: string; name: string; phone: string };
trip?: { id: string; tripNumber: string } | null;
}
export interface CreateOrderPayload {
shipmentNumber: string;
recipientName: string;
recipientPhone: string;
clientId: string;
tripId?: string;
subTotal?: number;
vatRate?: number;
vatAmount?: number;
totalPrice?: number;
paymentMethod?: PaymentMethod;
paymentStatus?: PaymentStatus;
type?: string;
quantity?: number;
weight?: number;
deliveryAddress?: OrderAddress;
pickupAddressId?: string;
}
export interface UpdateOrderPayload extends Partial<CreateOrderPayload> {
currentStatus?: OrderStatus;
}
export interface UpdateOrderStatusPayload {
status: OrderStatus;
reason?: string;
}
export interface TransferOrderPayload {
tripId: string;
}
export interface OrdersResponse {
data: Order[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}

4
types/role.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface Role {
id: string;
name: string;
}

54
types/user.ts Normal file
View File

@@ -0,0 +1,54 @@
export interface User {
id: string;
name: string;
userName?: string;
email?: string;
phone: string;
isActive: boolean;
createdAt: string;
role?: { id?: string; name: string };
branch?: { id?: string; name: string };
}
export interface UserFormData {
name: string;
email: string;
phone: string;
password: string;
roleId: string;
branchId: string;
}
export interface FormErrors {
name?: string;
email?: string;
phone?: string;
password?: string;
roleId?: string;
branchId?: string;
}
export interface ApiListResponse<T> {
data: {
data: T[];
pagination: { total: number; page: number; pages: number ; };
meta?: { total: number; pages: number };
};
}
export type TableState = {
users: User[];
loading: boolean;
total: number;
pages: number;
error: string | null;
};
export type TableAction =
| { type: "LOAD_START" }
| { type: "LOAD_OK"; users: User[]; total: number; pages: number }
| { type: "LOAD_ERR"; error: string }
| { type: "ADD"; user: User }
| { type: "UPDATE"; user: User }
| { type: "DELETE"; id: string }
| { type: "CLEAR_ERR" };

View File

@@ -1,16 +1,32 @@
import { FC } from "react"; import { FC } from "react";
const Logo: FC = () => ( interface LogoProps {
white?: boolean;
}
const Logo: FC<LogoProps> = ({ white = false }) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-8 h-8 bg-[#1A73E8] rounded-lg flex items-center justify-center flex-shrink-0"> <div style={{
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> width: 32, height: 32,
<path d="M5 17H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v3" /> background: white ? "rgba(255,255,255,0.15)" : "#2563EB",
<rect x="9" y="11" width="14" height="10" rx="2" /> borderRadius: 8,
<circle cx="12" cy="21" r="1" /> display: "flex", alignItems: "center", justifyContent: "center",
<circle cx="20" cy="21" r="1" /> }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 17H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v3"/>
<rect x="9" y="11" width="14" height="10" rx="2"/>
<circle cx="12" cy="21" r="1"/>
<circle cx="20" cy="21" r="1"/>
</svg> </svg>
</div> </div>
<span className="font-bold text-[15px] text-[#111827]">Slash.sa</span> <span style={{
fontWeight: 700,
fontSize: 15,
color: white ? "#FFFFFF" : "#0F172A",
}}>
Slash.sa
</span>
</div> </div>
); );