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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user