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:
@@ -1,89 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, type FC, type FormEvent } from "react";
|
||||
import { saveSession, type ClientSession } from "@/utils/helperFun";
|
||||
import { Spinner } from "../UI";
|
||||
import { saveSession, type ClientSession } from "@/lib/session";
|
||||
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 ───────────────────────────────────
|
||||
interface RegForm {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
interface RegErrors {
|
||||
name?: string;
|
||||
name?: string;
|
||||
email?: 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 ────────────────────────────
|
||||
interface RegisterStepProps {
|
||||
onSuccess: (s: ClientSession) => void;
|
||||
@@ -112,23 +50,17 @@ const RegisterStep: FC<RegisterStepProps> = ({ onSuccess }) => {
|
||||
if (!validate()) return;
|
||||
setLoading(true);
|
||||
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 = {
|
||||
id: data.id ?? data._id,
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
@@ -151,7 +83,7 @@ const RegisterStep: FC<RegisterStepProps> = ({ onSuccess }) => {
|
||||
<div dir="rtl">
|
||||
<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">
|
||||
<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>
|
||||
<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}
|
||||
onChange={set("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
|
||||
label="البريد الإلكتروني *"
|
||||
@@ -180,7 +112,7 @@ const RegisterStep: FC<RegisterStepProps> = ({ onSuccess }) => {
|
||||
value={form.email}
|
||||
onChange={set("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
|
||||
label="رقم الجوال *"
|
||||
@@ -189,21 +121,20 @@ const RegisterStep: FC<RegisterStepProps> = ({ onSuccess }) => {
|
||||
value={form.phone}
|
||||
onChange={set("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
|
||||
label="اسم الشركة (اختياري)"
|
||||
placeholder="شركة لوجي فلو للتوصيل"
|
||||
value={form.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">
|
||||
<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>
|
||||
</PrimaryBtn>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,111 +1,40 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect, type FC } from "react";
|
||||
import { type ClientSession } from "@/utils/helperFun";
|
||||
import { Spinner } from "../UI";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
|
||||
|
||||
// ─── Types ───────────────────────────────────
|
||||
export interface ClientAddress {
|
||||
_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;
|
||||
};
|
||||
}
|
||||
import { type ClientSession } from "@/lib/session";
|
||||
import { clientService } from "@/services/client.service";
|
||||
import type { ClientAddress } from "@/types/client";
|
||||
import { Spinner } from "../../Components/UI";
|
||||
import { Alert } from "../../Components/UI";
|
||||
import { Button } from "../../Components/UI";
|
||||
import { Input } from "../../Components/UI";
|
||||
|
||||
interface AddrForm {
|
||||
label: string;
|
||||
branchName: string;
|
||||
contactName: string;
|
||||
label: string;
|
||||
branchName: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
city: string;
|
||||
district: string;
|
||||
street: string;
|
||||
buildingNo: string;
|
||||
unitNo: string;
|
||||
zipCode: string;
|
||||
city: string;
|
||||
district: string;
|
||||
street: string;
|
||||
buildingNo: string;
|
||||
unitNo: string;
|
||||
zipCode: string;
|
||||
}
|
||||
|
||||
interface AddrErrors {
|
||||
city?: string;
|
||||
street?: string;
|
||||
zipCode?: string;
|
||||
city?: string;
|
||||
street?: string;
|
||||
zipCode?: 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 {
|
||||
session: ClientSession;
|
||||
session: ClientSession;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const LABEL_OPTIONS = ["عام", "المنزل", "المكتب الرئيسي", "المستودع", "الفرع"];
|
||||
|
||||
const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
|
||||
const [savedAddresses, setSavedAddresses] = useState<ClientAddress[]>([]);
|
||||
const [loadingAddrs, setLoadingAddrs] = useState(true);
|
||||
@@ -119,26 +48,14 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
|
||||
city: "", district: "", street: "", buildingNo: "", unitNo: "", zipCode: "",
|
||||
});
|
||||
|
||||
// get saved addresses on mount
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/client/${session.id}/addresses`);
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
// api might return either { addresses: [...] } or just [...]
|
||||
const list: ClientAddress[] = Array.isArray(data) ? data : (data.addresses ?? []);
|
||||
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);
|
||||
}
|
||||
})();
|
||||
clientService.getAddresses(session.id)
|
||||
.then(list => {
|
||||
setSavedAddresses(list);
|
||||
if (list.length > 0) setSelectedId(list[0]._id);
|
||||
})
|
||||
.catch(() => setShowNew(true))
|
||||
.finally(() => setLoadingAddrs(false));
|
||||
}, [session.id]);
|
||||
|
||||
const setF =
|
||||
@@ -165,7 +82,6 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
|
||||
|
||||
const handleNext = async () => {
|
||||
if (selectedId) {
|
||||
// existing address selected → proceed without API call
|
||||
setLoading(true);
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
setLoading(false);
|
||||
@@ -176,28 +92,19 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
|
||||
setLoading(true);
|
||||
setApiError("");
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/client/${session.id}/addresses`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
label: form.label,
|
||||
branchName: form.branchName || null,
|
||||
contactPerson: {
|
||||
name: form.contactName,
|
||||
phone: form.contactPhone,
|
||||
},
|
||||
details: {
|
||||
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,
|
||||
},
|
||||
}),
|
||||
await clientService.addAddress(session.id, {
|
||||
label: form.label,
|
||||
branchName: form.branchName || null,
|
||||
contactPerson: { name: form.contactName, phone: form.contactPhone },
|
||||
details: {
|
||||
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();
|
||||
} catch (err: unknown) {
|
||||
setApiError(err instanceof Error ? err.message : "تعذّر حفظ العنوان، يرجى المحاولة مجددًا.");
|
||||
@@ -210,7 +117,9 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
|
||||
<div dir="rtl">
|
||||
<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">
|
||||
<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>
|
||||
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight leading-tight">اختر عنوان التوصيل</h2>
|
||||
@@ -225,23 +134,24 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* loading state */}
|
||||
{loadingAddrs ? (
|
||||
<div className="flex items-center justify-center py-8 gap-2">
|
||||
<Spinner />
|
||||
<Spinner size="sm" />
|
||||
<span className="text-[13px] text-[#6B7280]">جارٍ تحميل العناوين…</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* saved addresses */}
|
||||
{/* Saved addresses */}
|
||||
{savedAddresses.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<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 => (
|
||||
<button
|
||||
key={addr._id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selectedId === addr._id}
|
||||
onClick={() => { setSelectedId(addr._id); setShowNew(false); }}
|
||||
className={`w-full text-right border rounded-xl p-3.5 transition-all duration-150 ${
|
||||
selectedId === addr._id
|
||||
@@ -250,11 +160,16 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
|
||||
}`}
|
||||
>
|
||||
<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 ${
|
||||
selectedId === addr._id ? "border-[#1A73E8] bg-[#1A73E8]" : "border-[#D1D5DB]"
|
||||
}`}>
|
||||
<div
|
||||
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 && (
|
||||
<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 className="flex-1">
|
||||
@@ -266,9 +181,7 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
|
||||
}`}>
|
||||
{addr.label}
|
||||
</span>
|
||||
{addr.branchName && (
|
||||
<span className="text-[12px] text-[#6B7280]">{addr.branchName}</span>
|
||||
)}
|
||||
{addr.branchName && <span className="text-[12px] text-[#6B7280]">{addr.branchName}</span>}
|
||||
</div>
|
||||
<p className="text-[13px] font-semibold text-[#111827]">
|
||||
{addr.details.street}
|
||||
@@ -292,38 +205,37 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* new address button */}
|
||||
{/* New address toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowNew(v => !v); setSelectedId(null); }}
|
||||
className={`w-full border-2 border-dashed rounded-xl p-3 flex items-center justify-center gap-2
|
||||
text-[13px] font-semibold transition-all ${
|
||||
aria-expanded={showNew}
|
||||
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
|
||||
? "border-[#1A73E8] text-[#1A73E8] bg-[#EBF3FF]"
|
||||
: "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="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
إضافة عنوان جديد
|
||||
</button>
|
||||
|
||||
{/* new address form */}
|
||||
{/* New address form */}
|
||||
{showNew && (
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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
|
||||
id="addr-label"
|
||||
value={form.label}
|
||||
onChange={setF("label")}
|
||||
className="h-10 px-3 border border-[#E5E7EB] rounded-lg text-[13px] text-[#111827]
|
||||
bg-white outline-none focus:border-[#1A73E8] focus:ring-2
|
||||
focus:ring-[#1A73E8]/15 text-right"
|
||||
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"
|
||||
dir="rtl"
|
||||
>
|
||||
{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} />
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-[#E5E7EB]" />
|
||||
<hr className="border-[#E5E7EB]" />
|
||||
|
||||
<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.district} onChange={setF("district")} />
|
||||
<Input label="المدينة *" placeholder="الرياض" value={form.city} onChange={setF("city")} error={formErrors.city} />
|
||||
<Input label="الحي" placeholder="العليا" value={form.district} onChange={setF("district")} />
|
||||
</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">
|
||||
<Input label="رقم المبنى" placeholder="12" value={form.buildingNo} onChange={setF("buildingNo")} />
|
||||
<Input label="رقم الوحدة" placeholder="3ب" value={form.unitNo} onChange={setF("unitNo")} />
|
||||
@@ -355,19 +267,20 @@ const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
|
||||
)}
|
||||
|
||||
{!canProceed && !loadingAddrs && (
|
||||
<p 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 role="alert" className="text-[12px] text-[#F59E0B] font-medium mt-3 flex items-center gap-1.5">
|
||||
يرجى اختيار عنوان أو إضافة عنوان جديد للمتابعة.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<PrimaryBtn onClick={handleNext} loading={loading} disabled={!canProceed || loadingAddrs} className="w-full">
|
||||
<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
|
||||
onClick={handleNext}
|
||||
loading={loading}
|
||||
disabled={!canProceed || loadingAddrs}
|
||||
fullWidth
|
||||
>
|
||||
التالي — عرض الطلبات
|
||||
</PrimaryBtn>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,74 +1,59 @@
|
||||
"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";
|
||||
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 ───────────────────────────
|
||||
// ─── Order status tracker ─────────────────────
|
||||
const STATUS_STEPS: OrderStatus[] = ["CREATED", "IN_TRANSIT", "DELIVERED"];
|
||||
const STEP_LABELS = ["تم الإنشاء", "قيد التوصيل", "تم التسليم"];
|
||||
const STEP_ICONS = [
|
||||
<svg key="c" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="3" width="6" height="4" rx="1"/>
|
||||
<path d="M4 7h16v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z"/>
|
||||
</svg>,
|
||||
<svg key="t" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2"/>
|
||||
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
|
||||
<line x1="12" y1="12" x2="12" y2="16"/>
|
||||
<line x1="10" y1="14" x2="14" y2="14"/>
|
||||
</svg>,
|
||||
<svg key="d" 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 STEP_LABELS = ["تم الإنشاء", "قيد التوصيل", "تم التسليم"];
|
||||
|
||||
const StepIcon: FC<{ step: number }> = ({ step }) => {
|
||||
if (step === 0) return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="3" width="6" height="4" rx="1"/>
|
||||
<path d="M4 7h16v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z"/>
|
||||
</svg>
|
||||
);
|
||||
if (step === 1) return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2"/>
|
||||
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
|
||||
<line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const OrderTracker: FC<{ status: OrderStatus }> = ({ status }) => {
|
||||
const idx = STATUS_STEPS.indexOf(status === "CANCELLED" ? "CREATED" : status);
|
||||
return (
|
||||
<div className="flex items-center gap-0 py-2" dir="rtl">
|
||||
<div className="flex items-center gap-0 py-2" dir="rtl" role="list" aria-label="تقدم الطلب">
|
||||
{STATUS_STEPS.map((s, i) => (
|
||||
<React.Fragment key={s}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex flex-col items-center gap-1" role="listitem">
|
||||
<div className={`w-9 h-9 rounded-full flex items-center justify-center transition-all ${
|
||||
status === "CANCELLED"
|
||||
? "bg-[#FEE2E2] text-red-500 border-2 border-red-200"
|
||||
: i <= idx
|
||||
? "bg-[#1A73E8] text-white"
|
||||
: "bg-white border-2 border-[#E5E7EB] text-[#D1D5DB]"
|
||||
}`}>
|
||||
{STEP_ICONS[i]}
|
||||
: i <= idx ? "bg-[#1A73E8] text-white" : "bg-white border-2 border-[#E5E7EB] text-[#D1D5DB]"
|
||||
}`} aria-current={i === idx ? "step" : undefined}>
|
||||
<StepIcon step={i} />
|
||||
</div>
|
||||
<span className={`text-[10px] font-semibold whitespace-nowrap ${
|
||||
status === "CANCELLED"
|
||||
? "text-red-400"
|
||||
: i <= idx ? "text-[#1A73E8]" : "text-[#9CA3AF]"
|
||||
status === "CANCELLED" ? "text-red-400" : i <= idx ? "text-[#1A73E8]" : "text-[#9CA3AF]"
|
||||
}`}>
|
||||
{STEP_LABELS[i]}
|
||||
</span>
|
||||
</div>
|
||||
{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]"
|
||||
}`} />
|
||||
)}
|
||||
@@ -78,43 +63,16 @@ const OrderTracker: FC<{ status: OrderStatus }> = ({ status }) => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
// ─── DashboardStep ───────────────────────────
|
||||
interface DashboardStepProps {
|
||||
session: ClientSession;
|
||||
}
|
||||
|
||||
const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const { orders, loading, error } = useClientOrders(session.id);
|
||||
|
||||
// جلب الطلبات من الـ API
|
||||
useEffect(() => {
|
||||
(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 activeOrder = orders.find(o => o.status === "CREATED" || o.status === "IN_TRANSIT");
|
||||
const pastOrders = orders.filter(o => o !== activeOrder);
|
||||
|
||||
const summary = {
|
||||
total: orders.length,
|
||||
@@ -124,41 +82,38 @@ const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
|
||||
|
||||
return (
|
||||
<div dir="rtl">
|
||||
{/* الرأس */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight">لوحة الطلبات</h2>
|
||||
<p className="text-[13px] text-[#6B7280] mt-0.5">
|
||||
مرحبًا بعودتك، <strong className="text-[#111827]">{session.name}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 bg-[#D1FAE5] border border-[#A7F3D0] rounded-full px-3 py-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[#10B981] animate-pulse" />
|
||||
<span className="text-[11px] font-bold text-[#065F46]">نشط</span>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight">لوحة الطلبات</h2>
|
||||
<p className="text-[13px] text-[#6B7280] mt-0.5">
|
||||
مرحبًا بعودتك، <strong className="text-[#111827]">{session.name}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 bg-[#D1FAE5] border border-[#A7F3D0] rounded-full px-3 py-1.5" role="status" aria-label="الحساب نشط">
|
||||
<span aria-hidden="true" className="w-2 h-2 rounded-full bg-[#10B981] motion-safe:animate-pulse" />
|
||||
<span className="text-[11px] font-bold text-[#065F46]">نشط</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* حالة التحميل أو الخطأ */}
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-14 gap-3 text-[#6B7280]">
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center py-14 gap-3" role="status">
|
||||
<Spinner />
|
||||
<p className="text-[13px]">جارٍ تحميل الطلبات…</p>
|
||||
<p className="text-[13px] text-[#6B7280]">جارٍ تحميل الطلبات…</p>
|
||||
</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>
|
||||
<button
|
||||
onClick={() => { setLoading(true); setError(""); }}
|
||||
className="text-[12px] text-[#1A73E8] underline"
|
||||
>
|
||||
إعادة المحاولة
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* إحصائيات */}
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
{[
|
||||
{ label: "إجمالي الطلبات", value: summary.total, color: "text-[#111827]" },
|
||||
@@ -172,7 +127,7 @@ const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* متتبع الطلب النشط */}
|
||||
{/* Active order tracker */}
|
||||
{activeOrder ? (
|
||||
<div className="bg-gradient-to-bl from-[#0D47A1] to-[#1A73E8] rounded-xl p-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -196,7 +151,7 @@ const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* جدول الطلبات السابقة */}
|
||||
{/* Past orders table */}
|
||||
{pastOrders.length > 0 && (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#E5E7EB] flex items-center justify-between">
|
||||
@@ -208,16 +163,15 @@ const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
|
||||
<thead>
|
||||
<tr className="bg-[#F9FAFB]">
|
||||
{["رقم الطلب", "التاريخ", "الوصف", "الحالة", "المبلغ"].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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pastOrders.map((order, i) => (
|
||||
<tr
|
||||
key={order.id}
|
||||
className={`border-t border-[#F3F4F6] hover:bg-[#FAFAFA] transition-colors ${i % 2 === 0 ? "" : "bg-[#FAFAFA]/30"}`}
|
||||
>
|
||||
<tr key={order.id} className={`border-t border-[#F3F4F6] hover:bg-[#FAFAFA] transition-colors ${i % 2 === 0 ? "" : "bg-[#FAFAFA]/30"}`}>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-[12px] font-semibold text-[#1A73E8]">{order.id}</span>
|
||||
</td>
|
||||
@@ -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">
|
||||
<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)}
|
||||
</span>
|
||||
</td>
|
||||
@@ -242,7 +196,6 @@ const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* مسح الجلسة */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { clearSession(); window.location.reload(); }}
|
||||
|
||||
58
Components/User/DeleteConfirmModal.tsx
Normal file
58
Components/User/DeleteConfirmModal.tsx
Normal 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
56
Components/User/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
217
Components/User/UserFormModal.tsx
Normal file
217
Components/User/UserFormModal.tsx
Normal 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 = "رقم هاتف غير صالح (10–15 رقم)";
|
||||
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>
|
||||
);
|
||||
}
|
||||
157
Components/User/UserTable.tsx
Normal file
157
Components/User/UserTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
"use client";
|
||||
// ─────────────────────────────────────────────
|
||||
// page.tsx
|
||||
// نقطة الدخول — تحقق من الجلسة وتوجيه المستخدم
|
||||
// ─────────────────────────────────────────────
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { loadSession, type ClientSession } from "@/utils/helperFun";
|
||||
import RegisterStep from "../../Components/Client/client";
|
||||
import DashboardStep from "../../Components/Order/order";
|
||||
import Logo from "@/utils/logo";
|
||||
import { loadSession, type ClientSession } from "@/lib/session";
|
||||
import RegisterStep from "../..//Components/Client/client";
|
||||
import DashboardStep from "../../Components/Order/order";
|
||||
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 }) {
|
||||
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">
|
||||
{session?.name && (
|
||||
<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">
|
||||
<circle cx="12" cy="8" r="4"/>
|
||||
<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() {
|
||||
const [step, setStep] = useState<Step>("bootstrap");
|
||||
const [session, setSession] = useState<ClientSession | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadSession();
|
||||
|
||||
if (saved?.id && saved.name) {
|
||||
// user has valid session → dashboard
|
||||
setSession(saved);
|
||||
setStep("dashboard");
|
||||
} else {
|
||||
// new or invalid session → register
|
||||
setStep("register");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRegistered = useCallback((s: ClientSession) => {
|
||||
setSession(s);
|
||||
setStep("address");
|
||||
}, []);
|
||||
|
||||
const handleAddressComplete = useCallback(() => {
|
||||
setStep("dashboard");
|
||||
}, []);
|
||||
|
||||
@@ -68,18 +59,28 @@ export default function ClientOrderPage() {
|
||||
className="min-h-screen bg-[#ECEEF2]"
|
||||
dir="rtl"
|
||||
lang="ar"
|
||||
style={{ fontFamily: "'Cairo', 'IBM Plex Sans Arabic', Tahoma, sans-serif" }}
|
||||
style={{ fontFamily: "var(--font-sans)" }}
|
||||
>
|
||||
<Navbar session={session} />
|
||||
|
||||
<div className="max-w-lg mx-auto px-4 py-8">
|
||||
<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" && (
|
||||
<RegisterStep onSuccess={handleRegistered} />
|
||||
)}
|
||||
|
||||
{step === "address" && session && (
|
||||
<AddressStep session={session} onSuccess={handleAddressComplete} />
|
||||
)}
|
||||
|
||||
{step === "dashboard" && session && (
|
||||
<DashboardStep session={session} />
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,417 @@
|
||||
export default function CarsPage() {
|
||||
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>;
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,130 @@
|
||||
// File: app/components/layout/Sidebar.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
// FIX: Import getStoredUser so the sidebar reads the authenticated user's
|
||||
// name and role from localStorage instead of showing hardcoded placeholders.
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getStoredUser } from "../../../lib/auth";
|
||||
import Logo from "../../../utils/logo";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", permission: "read-dashboard" },
|
||||
{ href: "/users", label: "Users", permission: "read-user" },
|
||||
{ href: "/cars", label: "Cars", permission: "read-car" },
|
||||
{ href: "/drivers", label: "Drivers", permission: "read-driver" },
|
||||
{ href: "/clients", label: "Clients", permission: "read-client" },
|
||||
{ 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" },
|
||||
{ href: "/audit", label: "Audit", permission: "read-audit" },
|
||||
const navSections = [
|
||||
{
|
||||
label: "الرئيسية",
|
||||
items: [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: "ti-layout-dashboard", permission: "read-dashboard" },
|
||||
{ href: "/users", label: "Users", icon: "ti-users", permission: "read-user" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "الأسطول",
|
||||
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() {
|
||||
// FIX: Read the stored user on render. suppressHydrationWarning handles
|
||||
// the SSR/CSR mismatch since localStorage is browser-only.
|
||||
const user = getStoredUser();
|
||||
const permissions = user?.permissions ?? navItems.map((i) => i.permission);
|
||||
const pathname = usePathname();
|
||||
const user = getStoredUser();
|
||||
const permissions = user?.permissions ?? navSections.flatMap(s => s.items.map(i => i.permission));
|
||||
|
||||
return (
|
||||
<aside
|
||||
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>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-cyan-200">Logistics</p>
|
||||
<h2 className="mt-3 text-xl font-semibold text-white">Ops Console</h2>
|
||||
<p className="mt-2 text-sm text-slate-300">
|
||||
Fleet, clients, orders, and compliance in one place.
|
||||
</p>
|
||||
{/* Logo */}
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<Logo white />
|
||||
</div>
|
||||
|
||||
<nav className="mt-8 space-y-2">
|
||||
{navItems.map((item) => {
|
||||
// FIX: Check the real user permissions array fetched from stored auth.
|
||||
// Dashboard is always visible regardless of permissions.
|
||||
const hasPermission =
|
||||
item.permission === "read-dashboard" ||
|
||||
permissions.includes(item.permission);
|
||||
if (!hasPermission) return null;
|
||||
{/* Nav sections */}
|
||||
<nav
|
||||
style={{ flex: 1, display: "flex", flexDirection: "column", gap: 0 }}
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{navSections.map((section) => (
|
||||
<div key={section.label} style={{ marginBottom: 8 }}>
|
||||
<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 (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
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"
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span className="text-xs text-slate-400">→</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{section.items.map((item) => {
|
||||
const allowed = item.permission === "read-dashboard"
|
||||
|| permissions.includes(item.permission);
|
||||
if (!allowed) return null;
|
||||
|
||||
const active = pathname === item.href
|
||||
|| (item.href !== "/dashboard" && pathname.startsWith(item.href));
|
||||
|
||||
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>
|
||||
|
||||
<div className="mt-auto rounded-3xl border border-emerald-400/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
|
||||
<p className="text-xs uppercase tracking-[0.25em] text-emerald-100">Authenticated</p>
|
||||
{/* FIX: Show the real user name and role from the stored auth session,
|
||||
replacing the previous hardcoded "Operations user" / "Signed in" text. */}
|
||||
<p suppressHydrationWarning className="mt-2 font-semibold">
|
||||
{/* User badge */}
|
||||
<div style={{
|
||||
background: "rgba(255,255,255,0.12)",
|
||||
borderRadius: 10,
|
||||
padding: "10px 12px",
|
||||
marginTop: 8,
|
||||
}}>
|
||||
<p suppressHydrationWarning style={{ fontSize: 13, fontWeight: 500, color: "#fff", margin: 0 }}>
|
||||
{user?.name ?? user?.userName ?? "—"}
|
||||
</p>
|
||||
<p suppressHydrationWarning className="text-xs text-emerald-100/90">
|
||||
{user?.role ? `Role: ${user.role}` : "Signed in"}
|
||||
<p suppressHydrationWarning style={{ fontSize: 11, color: "rgba(255,255,255,0.55)", marginTop: 2 }}>
|
||||
{user?.role ?? "Signed in"}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,68 +1,72 @@
|
||||
// File: app/components/layout/Topbar.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
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";
|
||||
|
||||
export function Topbar() {
|
||||
const router = useRouter();
|
||||
|
||||
function handleLogout() {
|
||||
// FIX: clearAuth already existed and is correct — it removes both the
|
||||
// token and user from localStorage, fully ending the session.
|
||||
clearAuth();
|
||||
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();
|
||||
// Capitalize the role label for display (e.g. "admin" → "Admin")
|
||||
const roleLabel = user?.role
|
||||
? user.role.charAt(0).toUpperCase() + user.role.slice(1)
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<header
|
||||
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>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-cyan-200">
|
||||
Operations dashboard
|
||||
</p>
|
||||
<h1 className="text-xl font-semibold text-white lg:text-2xl">
|
||||
Logistics delivery management
|
||||
</h1>
|
||||
<div>
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
|
||||
لوحة التحكم
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-muted)", margin: 0 }}>
|
||||
Operations dashboard
|
||||
</p>
|
||||
</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 className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-full border border-white/10 bg-slate-900/80 px-4 py-2 text-sm text-slate-200 hover:bg-slate-800"
|
||||
>
|
||||
Overview
|
||||
</Link>
|
||||
{/* FIX: Replace the hardcoded "Manager" string with the actual
|
||||
authenticated user's role read from localStorage. */}
|
||||
<div
|
||||
suppressHydrationWarning
|
||||
className="hidden rounded-full border border-white/10 bg-slate-900/80 px-4 py-2 text-sm text-slate-200 md:block"
|
||||
>
|
||||
{roleLabel}
|
||||
</div>
|
||||
<button
|
||||
type="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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: "#DC2626",
|
||||
background: "#FEF2F2",
|
||||
border: "1px solid #FECACA",
|
||||
borderRadius: "var(--radius-full)",
|
||||
padding: "5px 14px",
|
||||
cursor: "pointer",
|
||||
transition: "var(--transition-base)",
|
||||
}}
|
||||
>
|
||||
تسجيل الخروج
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
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 (
|
||||
<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">
|
||||
<Sidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Topbar />
|
||||
<main className="flex-1 p-4 lg:p-6">{children}</main>
|
||||
</div>
|
||||
<div className="flex min-h-screen" style={{ background: "var(--color-surface-muted)" }}>
|
||||
<Sidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Topbar />
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,160 +2,219 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { getDashboardSummary, type DashboardSummaryResponse } from "../../lib/api";
|
||||
import { clearAuth, getStoredToken, getStoredUser } from "../../lib/auth";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useDashboardSummary } from "../../hooks/useDashboardSummary";
|
||||
import { clearAuth, getStoredUser } from "../../lib/auth";
|
||||
import { Spinner, Alert } from "../../Components/UI";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<DashboardSummaryResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { data, error, loading } = useDashboardSummary();
|
||||
|
||||
useEffect(() => {
|
||||
if (!getStoredToken()) {
|
||||
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 storedUser = getStoredUser();
|
||||
const role = typeof storedUser?.role === "string" ? storedUser.role : "";
|
||||
if (!role || ["driver", "سائق"].includes(role)) {
|
||||
clearAuth(); // add this import at top of file
|
||||
clearAuth();
|
||||
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]);
|
||||
|
||||
const stats = data?.data?.stats;
|
||||
const alerts = data?.data?.alerts;
|
||||
const activeTrips = data?.data?.activeTrips || [];
|
||||
const stats = data?.stats;
|
||||
const alerts = data?.alerts;
|
||||
const activeTrips = data?.activeTrips || [];
|
||||
|
||||
const summaryCards = useMemo(
|
||||
() => [
|
||||
{ label: "العملاء", value: stats?.clients ?? 0, tone: "from-cyan-500 to-sky-600" },
|
||||
{ label: "الطلبات", value: stats?.orders ?? 0, tone: "from-violet-500 to-fuchsia-600" },
|
||||
{ label: "الرحلات", value: stats?.trips ?? 0, tone: "from-emerald-500 to-green-600" },
|
||||
{ label: "المركبات", value: (stats?.cars ?? 0) + (stats?.drivers ?? 0), tone: "from-amber-400 to-orange-500" },
|
||||
{ label: "العملاء", value: stats?.clients ?? 0, accent: "#06B6D4" },
|
||||
{ label: "الطلبات", value: stats?.orders ?? 0, accent: "#A78BFA" },
|
||||
{ label: "الرحلات", value: stats?.trips ?? 0, accent: "#34D399" },
|
||||
{ label: "المركبات", value: (stats?.cars ?? 0) + (stats?.drivers ?? 0), accent: "#FBBF24" },
|
||||
],
|
||||
[stats],
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,#0f172a_0%,#111827_45%,#020617_100%)] text-slate-100">
|
||||
<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">
|
||||
<p className="text-sm uppercase tracking-[0.35em] text-cyan-200">لوحة العمليات</p>
|
||||
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-white lg:text-4xl">ذكاء الأسطول في لمحة سريعة</h1>
|
||||
<p className="mt-3 max-w-3xl text-slate-300">هذه الشاشة متوافقة مع نقطة ملخص لوحة الإدارة في الخلفية وتوفر عرضًا واضحًا لطلب الأسطول، وتنبيهات السلامة، وتقدم الرحلات.</p>
|
||||
</div>
|
||||
<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>
|
||||
<section className="flex flex-col gap-6">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<header
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
border: "1px solid var(--color-border-dark)",
|
||||
borderRadius: "var(--radius-2xl)",
|
||||
padding: "2rem",
|
||||
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>
|
||||
</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>}
|
||||
{error && <p className="rounded-2xl border border-rose-400/30 bg-rose-500/10 p-4 text-rose-100">{error}</p>}
|
||||
{/* ── Loading ── */}
|
||||
{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 && (
|
||||
<>
|
||||
<section className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map((item) => (
|
||||
<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>
|
||||
{/* ── Error ── */}
|
||||
{error && (
|
||||
<Alert type="error" message={error}
|
||||
className="border-rose-400/30 bg-rose-500/10 text-rose-100" />
|
||||
)}
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<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">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.25em] text-cyan-200">الرحلات النشطة</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">تقدم الرحلات</h2>
|
||||
</div>
|
||||
<span className="rounded-full bg-emerald-400/10 px-3 py-1 text-xs text-emerald-200">مباشر من /dashboard/summary</span>
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* ── Summary cards ── */}
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map((item) => (
|
||||
<article
|
||||
key={item.label}
|
||||
style={{
|
||||
borderRadius: "var(--radius-2xl)",
|
||||
border: "1px solid var(--color-border-dark)",
|
||||
background: "var(--color-surface-dark-card)",
|
||||
padding: "1.25rem",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 3, borderRadius: 999, background: item.accent, marginBottom: "1rem" }} />
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-dark-muted)" }}>
|
||||
{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 className="mt-6 space-y-4">
|
||||
{activeTrips.length ? activeTrips.map((trip) => (
|
||||
<div key={trip.id} className="rounded-2xl border border-white/10 bg-slate-800/80 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{trip.tripNumber}</p>
|
||||
<p className="text-sm text-slate-300">{trip.title}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-cyan-400/10 px-3 py-1 text-xs text-cyan-100">{trip.progress}%</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 rounded-full bg-slate-700">
|
||||
<div className="h-2 rounded-full bg-linear-to-r from-cyan-400 to-emerald-400" style={{ width: `${trip.progress}%` }} />
|
||||
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(52,211,153,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#A7F3D0" }}>
|
||||
مباشر
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{activeTrips.length ? activeTrips.map((trip) => (
|
||||
<div key={trip.id} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "1rem" }}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)" }}>{trip.tripNumber}</p>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)" }}>{trip.title}</p>
|
||||
</div>
|
||||
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(103,232,249,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#CFFAFE" }}>
|
||||
{trip.progress}%
|
||||
</span>
|
||||
</div>
|
||||
)) : <p className="text-sm text-slate-300">لا توجد رحلات في-progress حالياً.</p>}
|
||||
</div>
|
||||
</article>
|
||||
<div style={{ marginTop: "0.75rem", height: 6, borderRadius: 999, background: "rgba(255,255,255,0.08)" }}>
|
||||
<div style={{ height: 6, borderRadius: 999, width: `${trip.progress}%`, background: "linear-gradient(90deg,#06B6D4,#34D399)" }} />
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)" }}>لا توجد رحلات نشطة حالياً.</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20">
|
||||
<p className="text-sm uppercase tracking-[0.25em] text-amber-200">تنبيهات الالتزام</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">التجديدات القادمة</h2>
|
||||
<div className="mt-6 space-y-4 text-sm text-slate-200">
|
||||
{(alerts?.expiringCars || []).slice(0, 3).map((item, index) => (
|
||||
<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>
|
||||
))}
|
||||
{(alerts?.expiringDrivers || []).slice(0, 3).map((item, index) => (
|
||||
<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?.upcomingMaint || []).slice(0, 3).map((item, index) => (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#FCD34D" }}>تنبيهات الالتزام</p>
|
||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>التجديدات القادمة</h2>
|
||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
{(alerts?.expiringCars || []).slice(0, 3).map((item, i) => (
|
||||
<div key={`car-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,191,36,0.20)", background: "rgba(251,191,36,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)" }}>
|
||||
{String((item as Record<string, unknown>).message || "Vehicle expiry alert")}
|
||||
</div>
|
||||
))}
|
||||
{(alerts?.expiringDrivers || []).slice(0, 3).map((item, i) => (
|
||||
<div key={`driver-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,113,133,0.20)", background: "rgba(244,63,94,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)" }}>
|
||||
{String((item as Record<string, unknown>).message || "Driver expiry alert")}
|
||||
</div>
|
||||
))}
|
||||
{(alerts?.upcomingMaint || []).slice(0, 3).map((item, i) => (
|
||||
<div key={`maint-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(103,232,249,0.20)", background: "rgba(103,232,249,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)" }}>
|
||||
{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]">
|
||||
<article className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20">
|
||||
<p className="text-sm uppercase tracking-[0.25em] text-violet-200">الأمان</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">لمحة الحساب</h2>
|
||||
<div className="mt-6 space-y-4">
|
||||
<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 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>
|
||||
{/* ── Security + endpoints ── */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#C4B5FD" }}>الأمان</p>
|
||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>لمحة الحساب</h2>
|
||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<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>
|
||||
</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">
|
||||
<p className="text-sm uppercase tracking-[0.25em] text-emerald-200">خريطة الخلفية</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">النقاط النهائية المستندة إلى Swagger</h2>
|
||||
<ul className="mt-6 grid gap-3 text-sm text-slate-200">
|
||||
<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>
|
||||
<li className="rounded-2xl border border-white/10 bg-slate-800/80 p-4">/v1/orders — دورة شحن الطلبات</li>
|
||||
<li className="rounded-2xl border border-white/10 bg-slate-800/80 p-4">/v1/trip — تنظيم الرحلات</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#6EE7B7" }}>خريطة الخلفية</p>
|
||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>النقاط النهائية</h2>
|
||||
<ul style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem", listStyle: "none", padding: 0 }}>
|
||||
{[
|
||||
"/v1/dashboard/summary — نظرة عامة تشغيلية",
|
||||
"/v1/client — إدارة العملاء",
|
||||
"/v1/orders — دورة شحن الطلبات",
|
||||
"/v1/trip — تنظيم الرحلات",
|
||||
].map((ep) => (
|
||||
<li key={ep} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-dark-muted)" }}>
|
||||
{ep}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,247 @@
|
||||
export default function DriversPage() {
|
||||
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>;
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "../styles/tokens.css";
|
||||
@import "tailwindcss";
|
||||
|
||||
|
||||
/* ── Base resets ────────────────────────────────────── */
|
||||
*,
|
||||
*::before,
|
||||
@@ -27,11 +28,60 @@ body {
|
||||
/* ── Tailwind theme bridge ──────────────────────────── */
|
||||
/* Expose CSS variables as Tailwind color utilities. */
|
||||
@theme inline {
|
||||
--color-background: var(--color-surface-sunken);
|
||||
--color-foreground: var(--color-text-primary);
|
||||
--color-brand: var(--color-brand-600);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--color-primary-*: initial;
|
||||
--color-sand-*: initial;
|
||||
--color-gold-*: initial;
|
||||
--color-slate-*: initial;
|
||||
|
||||
--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 ─────────────────────────────── */
|
||||
|
||||
@@ -4,38 +4,32 @@ import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { loginUser } from "../../lib/auth";
|
||||
import Logo from "@/utils/logo";
|
||||
import { Spinner } from "@/Components/UI";
|
||||
import Logo from "../../utils/logo";
|
||||
import { Button } from "../../Components/UI";
|
||||
import { Input } from "../../Components/UI";
|
||||
import { Alert } from "../../Components/UI";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [identity, setIdentity] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await loginUser(identity, password);
|
||||
|
||||
window.location.href = "/dashboard"; // Use full page reload to ensure all client state is reset after login
|
||||
console.log("Login successful", { identity });
|
||||
window.location.href = "/dashboard";
|
||||
} catch (err) {
|
||||
// FIX: Surface specific error messages from auth.ts (invalid credentials,
|
||||
// role denial, network failure) directly to the user.
|
||||
if (err instanceof Error) {
|
||||
// Network/fetch failure signals
|
||||
if (
|
||||
err.message.includes("fetch") ||
|
||||
err.message.includes("network") ||
|
||||
err.message.includes("Failed to fetch")
|
||||
) {
|
||||
setError(
|
||||
"Unable to connect. Please check your internet connection and try again later.",
|
||||
);
|
||||
const msg = err.message.toLowerCase();
|
||||
if (msg.includes("fetch") || msg.includes("network") || msg.includes("failed to fetch")) {
|
||||
setError("Unable to connect. Please check your internet connection and try again later.");
|
||||
} else {
|
||||
setError(err.message);
|
||||
}
|
||||
@@ -50,45 +44,37 @@ export default function LoginPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-100 text-slate-900">
|
||||
<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">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)",
|
||||
backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)",
|
||||
backgroundSize: "40px 40px",
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex h-full flex-col justify-between">
|
||||
<div>
|
||||
<Logo />
|
||||
<Logo white={true} />
|
||||
<h1 className="mt-10 max-w-md text-[36px] font-bold leading-tight tracking-[-0.5px]">
|
||||
اللوجستيات ببساطة، والتسليم بثقة.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-[15px] leading-7 text-white/75">
|
||||
راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب
|
||||
من خلال تسجيل دخول آمن واحد.
|
||||
راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب من خلال تسجيل دخول آمن واحد.
|
||||
</p>
|
||||
<div className="mt-8 space-y-3">
|
||||
{[
|
||||
[
|
||||
"التنسيق اللحظي",
|
||||
"تتبع كل طلب من الاستلام إلى التسليم في مكان واحد.",
|
||||
],
|
||||
[
|
||||
"وصول آمن",
|
||||
"الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة.",
|
||||
],
|
||||
[
|
||||
"عمليات سريعة",
|
||||
"استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات.",
|
||||
],
|
||||
["التنسيق اللحظي", "تتبع كل طلب من الاستلام إلى التسليم في مكان واحد."],
|
||||
["وصول آمن", "الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة."],
|
||||
["عمليات سريعة", "استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات."],
|
||||
].map(([title, desc]) => (
|
||||
<article
|
||||
key={title}
|
||||
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>
|
||||
@@ -99,71 +85,61 @@ export default function LoginPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] text-white/50">
|
||||
مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.
|
||||
</p>
|
||||
<p className="text-[12px] text-white/50">مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Right panel ────────────────────────────────── */}
|
||||
<section className="flex items-center justify-center bg-slate-50 px-6 py-10 lg:px-8">
|
||||
<form
|
||||
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>
|
||||
<p className="mt-1 text-[14px] text-slate-500">
|
||||
سجل الدخول لإدارة عمليات التسليم
|
||||
</p>
|
||||
<p className="text-[28px] font-bold text-slate-900">مرحبًا بعودتك</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">
|
||||
البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم
|
||||
<input
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
<Input
|
||||
label="البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم"
|
||||
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}
|
||||
onChange={(event) => setIdentity(event.target.value)}
|
||||
onChange={(e) => setIdentity(e.target.value)}
|
||||
placeholder="name@company.com / 05xxxxxxxx / username"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-4 block text-[11px] font-bold uppercase tracking-[0.5px] text-slate-500">
|
||||
كلمة المرور
|
||||
<input
|
||||
<Input
|
||||
label="كلمة المرور"
|
||||
type="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}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-[12px] text-blue-600 hover:underline"
|
||||
>
|
||||
<Link href="/forgot-password" className="text-[12px] text-blue-600 hover:underline">
|
||||
هل نسيت كلمة المرور؟
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-4 rounded-[9px] border border-red-200 bg-red-50 p-3 text-[13px] text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{error && (
|
||||
<div className="mt-4">
|
||||
<Alert type="error" message={error} onClose={() => setError(null)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={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"
|
||||
loading={loading}
|
||||
fullWidth
|
||||
className="mt-6 h-11"
|
||||
>
|
||||
{loading ? <Spinner /> : "تسجيل الدخول"}
|
||||
</button>
|
||||
{loading ? "جاري تسجيل الدخول…" : "تسجيل الدخول"}
|
||||
</Button>
|
||||
|
||||
<p className="mt-4 text-center text-[13px] text-slate-500">
|
||||
ليس لديك حساب؟{" "}
|
||||
|
||||
@@ -1,3 +1,281 @@
|
||||
export default function OrdersPage() {
|
||||
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>;
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
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
181
hooks/useOrder.ts
Normal 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
166
hooks/useUser.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
93
lib/auth.ts
93
lib/auth.ts
@@ -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_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 ───────────────────────────
|
||||
function setTokenCookie(token: string) {
|
||||
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`;
|
||||
}
|
||||
|
||||
@@ -16,25 +17,7 @@ function deleteTokenCookie() {
|
||||
document.cookie = `${AUTH_TOKEN_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────
|
||||
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 ─────────────────────────────────
|
||||
// ─── Storage ──────────────────────────────────
|
||||
export function getStoredToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
@@ -44,19 +27,13 @@ export function getStoredUser(): AuthUser | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = localStorage.getItem(AUTH_USER_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as AuthUser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try { return JSON.parse(raw) as AuthUser; } catch { return null; }
|
||||
}
|
||||
|
||||
export function saveAuth(token: string, user: AuthUser) {
|
||||
if (typeof window === "undefined") return;
|
||||
//save in localStorage for client-side access
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, token);
|
||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user));
|
||||
// save in cookie for server-side access (e.g., in middleware)
|
||||
setTokenCookie(token);
|
||||
}
|
||||
|
||||
@@ -67,72 +44,44 @@ export function clearAuth() {
|
||||
deleteTokenCookie();
|
||||
}
|
||||
|
||||
// ─── Blocked Roles ────────────────────────────
|
||||
// ─── Blocked roles ────────────────────────────
|
||||
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 ────────────────────────────────────
|
||||
export async function loginUser(
|
||||
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);
|
||||
type RawRole = { name?: string; permissions?: Array<{ permission?: { slug?: string } }> } | string;
|
||||
|
||||
const token = payload.data?.token ?? payload.token;
|
||||
const user = payload.data?.user ?? payload.user;
|
||||
export async function loginUser(identity: string, password: string) {
|
||||
const payload = await authService.login({ identity, password });
|
||||
|
||||
const { token, user } = payload.data || {};
|
||||
console.log({ token, user } );
|
||||
if (!token || !user) {
|
||||
throw new Error("اسم المستخدم أو كلمة المرور غير صحيحة.");
|
||||
}
|
||||
|
||||
// destracture role from user and determine roleName
|
||||
const rawRole = (user as unknown as RawUser).role;
|
||||
const roleName: string | undefined =
|
||||
typeof rawRole === "string"
|
||||
? rawRole
|
||||
: typeof rawRole === "object" && rawRole !== null
|
||||
? rawRole.name
|
||||
: undefined;
|
||||
const rawRole = (user as unknown as { role?: RawRole }).role;
|
||||
const roleName = typeof rawRole === "string"
|
||||
? rawRole
|
||||
: typeof rawRole === "object" && rawRole !== null
|
||||
? rawRole.name
|
||||
: undefined;
|
||||
|
||||
// reject if role is in blocked list
|
||||
if (roleName && (BLOCKED_ROLES as readonly string[]).includes(roleName)) {
|
||||
throw new Error("غير مصرح لك بالوصول إلى هذه اللوحة.");
|
||||
}
|
||||
|
||||
//fetch permissions
|
||||
const rolePermissions =
|
||||
typeof rawRole === "object" && rawRole !== null
|
||||
? rawRole.permissions ?? []
|
||||
? (rawRole as Exclude<RawRole, string>).permissions ?? []
|
||||
: [];
|
||||
|
||||
const permissions = Array.isArray(rolePermissions)
|
||||
? rolePermissions
|
||||
.map((entry) => entry?.permission?.slug)
|
||||
.filter((slug): slug is string => Boolean(slug))
|
||||
.map(e => e?.permission?.slug)
|
||||
.filter((s): s is string => Boolean(s))
|
||||
: [];
|
||||
|
||||
const fullUser: AuthUser = { ...user, role: roleName, permissions };
|
||||
|
||||
// save auth data in both localStorage and cookie for client and server access
|
||||
saveAuth(token, fullUser);
|
||||
|
||||
return { token, user: fullUser };
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
|
||||
// All authentication API calls in one place.
|
||||
|
||||
import { post } from "./api";
|
||||
import type { LoginResponse } from "../types/auth";
|
||||
|
||||
|
||||
2
services/car.service.ts
Normal file
2
services/car.service.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
export const carService = {};
|
||||
1
services/driver.service.ts
Normal file
1
services/driver.service.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const driverService = {};
|
||||
@@ -2,3 +2,6 @@ export { request, get, post, put, del, ApiError } from "./api";
|
||||
export { authService } from "./auth.service";
|
||||
export { dashboardService } from "./dashboard.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
45
services/order.service.ts
Normal 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
55
services/user.service.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
/* ── Brand palette ─────────────────────────────────── */
|
||||
--color-brand-50: #EBF3FF;
|
||||
--color-brand-100: #BFDBFE;
|
||||
--color-brand-600: #1A73E8;
|
||||
--color-brand-700: #1557B0;
|
||||
--color-brand-900: #0D47A1;
|
||||
/* ── Brand blue — مأخوذ من login مباشرة ── */
|
||||
--color-brand-50: #EFF6FF;
|
||||
--color-brand-100: #DBEAFE;
|
||||
--color-brand-200: #BFDBFE;
|
||||
--color-brand-300: #93C5FD;
|
||||
--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 ─────────────────────────────── */
|
||||
--color-success: #34A853;
|
||||
--color-success-light: #D1FAE5;
|
||||
--color-warning: #F59E0B;
|
||||
--color-warning-light: #FEF3C7;
|
||||
--color-danger: #E53935;
|
||||
--color-danger-light: #FEE2E2;
|
||||
--color-info: #1A73E8;
|
||||
--color-info-light: #EBF3FF;
|
||||
/* ── Sidebar gradient stops ── */
|
||||
--sidebar-grad-top: #1E3A8A;
|
||||
--sidebar-grad-mid: #1D4ED8;
|
||||
--sidebar-grad-bottom: #1565C0;
|
||||
|
||||
/* ── 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-muted: #F9FAFB;
|
||||
--color-surface-sunken: #ECEEF2;
|
||||
--color-border: #E5E7EB;
|
||||
--color-border-strong: #D1D5DB;
|
||||
--color-surface-muted: var(--color-slate-50);
|
||||
--color-surface-sunken: var(--color-slate-100);
|
||||
--color-border: var(--color-slate-200);
|
||||
--color-border-strong: var(--color-slate-300);
|
||||
|
||||
/* ── Text (light mode) ─────────────────────────────── */
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary:#374151;
|
||||
--color-text-muted: #6B7280;
|
||||
--color-text-hint: #9CA3AF;
|
||||
--color-text-inverse: #FFFFFF;
|
||||
/* ── Text ── */
|
||||
--color-text-primary: var(--color-slate-900);
|
||||
--color-text-secondary: var(--color-slate-600);
|
||||
--color-text-muted: var(--color-slate-500);
|
||||
--color-text-hint: var(--color-slate-400);
|
||||
--color-text-inverse: #FFFFFF;
|
||||
|
||||
/* ── Surface (dark — admin dashboard) ─────────────── */
|
||||
--color-surface-dark: #020617; /* slate-950 */
|
||||
--color-surface-dark-raised: #0F172A; /* slate-900 */
|
||||
--color-surface-dark-card: #1E293B; /* slate-800 */
|
||||
--color-border-dark: rgba(255, 255, 255, 0.10);
|
||||
--color-text-dark-primary: #F1F5F9; /* slate-100 */
|
||||
--color-text-dark-muted: #94A3B8; /* slate-400 */
|
||||
/* ── للـ dashboard pages — light بدل dark ── */
|
||||
--color-surface-dark: var(--color-slate-50);
|
||||
--color-surface-dark-raised: var(--color-surface);
|
||||
--color-surface-dark-card: var(--color-surface);
|
||||
--color-border-dark: var(--color-slate-200);
|
||||
--color-text-dark-primary: var(--color-slate-900);
|
||||
--color-text-dark-muted: var(--color-slate-500);
|
||||
|
||||
/* ── Typography ────────────────────────────────────── */
|
||||
/* ── Typography ── */
|
||||
--font-sans: 'Plus Jakarta Sans', sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', monospace;
|
||||
|
||||
/* ── Radius ────────────────────────────────────────── */
|
||||
--radius-sm: 7px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-2xl: 24px;
|
||||
/* ── Radius ── */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-2xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ── Spacing / layout ──────────────────────────────── */
|
||||
--sidebar-width: 280px;
|
||||
--topbar-height: 56px;
|
||||
--page-max-width: 1280px;
|
||||
--page-padding-x: 24px;
|
||||
/* ── Layout ── */
|
||||
--sidebar-width: 240px;
|
||||
--topbar-height: 56px;
|
||||
--page-padding-x: 24px;
|
||||
|
||||
/* ── Shadows ───────────────────────────────────────── */
|
||||
--shadow-card: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04);
|
||||
--shadow-overlay: 0 20px 60px rgba(0,0,0,.25);
|
||||
/* ── Shadows ── */
|
||||
--shadow-card: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
|
||||
--shadow-overlay: 0 10px 40px rgba(0,0,0,.1);
|
||||
|
||||
/* ── Transitions ───────────────────────────────────── */
|
||||
/* ── Transitions ── */
|
||||
--transition-base: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* ── Reduced-motion: disable spinner animation ──────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-spin {
|
||||
animation: none;
|
||||
}
|
||||
.animate-spin { animation: none; }
|
||||
}
|
||||
4
types/branch.ts
Normal file
4
types/branch.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Branch {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
97
types/order.ts
Normal file
97
types/order.ts
Normal 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
4
types/role.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
54
types/user.ts
Normal file
54
types/user.ts
Normal 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" };
|
||||
@@ -1,16 +1,32 @@
|
||||
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="w-8 h-8 bg-[#1A73E8] rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<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" />
|
||||
<div style={{
|
||||
width: 32, height: 32,
|
||||
background: white ? "rgba(255,255,255,0.15)" : "#2563EB",
|
||||
borderRadius: 8,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user