create driver page and client page also create oll driver , client layer
This commit is contained in:
392
Components/Client/Addressformmodal.tsx
Normal file
392
Components/Client/Addressformmodal.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import type {
|
||||
ClientAddress,
|
||||
ClientAddressFormData,
|
||||
ClientAddressFormErrors,
|
||||
} from "../../types/client";
|
||||
|
||||
// ── Fixed styles ───────────────────────────────────────────────────────────
|
||||
const S = {
|
||||
input: {
|
||||
width: "100%",
|
||||
height: 40,
|
||||
padding: "0 0.75rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-primary)",
|
||||
outline: "none",
|
||||
fontFamily: "var(--font-sans)",
|
||||
} as React.CSSProperties,
|
||||
label: {
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
} as React.CSSProperties,
|
||||
errorText: {
|
||||
fontSize: 11,
|
||||
color: "var(--color-danger)",
|
||||
fontWeight: 500,
|
||||
} as React.CSSProperties,
|
||||
};
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────────────────
|
||||
function validate(data: ClientAddressFormData): ClientAddressFormErrors {
|
||||
const e: ClientAddressFormErrors = {};
|
||||
if (!data.label.trim()) e.label = "النوع مطلوب";
|
||||
if (!data.street.trim()) e.street = "العنوان مطلوب";
|
||||
if (!data.city.trim()) e.city = "المدينة مطلوبة";
|
||||
if (!data.state.trim()) e.state = "المنطقة مطلوبة";
|
||||
if (!data.postalCode.trim()) e.postalCode = "الرمز البريدي مطلوب";
|
||||
if (!data.country.trim()) e.country = "الدولة مطلوبة";
|
||||
return e;
|
||||
}
|
||||
|
||||
const LABEL_PRESETS = ["فوترة", "شحن", "المقر الرئيسي", "فرع", "مستودع", "أخرى"];
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface AddressFormModalProps {
|
||||
editAddress: ClientAddress | null; // null = create mode
|
||||
onClose: () => void;
|
||||
onSubmit: (data: ClientAddressFormData, isNew: boolean) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
export function AddressFormModal({
|
||||
editAddress,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: AddressFormModalProps) {
|
||||
const isNew = editAddress === null;
|
||||
|
||||
const [form, setForm] = useState<ClientAddressFormData>({
|
||||
label: editAddress?.label ?? "",
|
||||
street: editAddress?.street ?? "",
|
||||
city: editAddress?.city ?? "",
|
||||
state: editAddress?.state ?? "",
|
||||
postalCode: editAddress?.postalCode ?? "",
|
||||
country: editAddress?.country ?? "المملكة العربية السعودية",
|
||||
isPrimary: editAddress?.isPrimary ?? false,
|
||||
});
|
||||
const [errors, setErrors] = useState<ClientAddressFormErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [apiError, setApiError] = useState("");
|
||||
const firstRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
useEffect(() => { firstRef.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const set = (field: keyof ClientAddressFormData) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setForm((p) => ({ ...p, [field]: e.target.value }));
|
||||
if (errors[field as keyof ClientAddressFormErrors])
|
||||
setErrors((p) => ({ ...p, [field]: undefined }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const errs = validate(form);
|
||||
if (Object.keys(errs).length) { setErrors(errs); return; }
|
||||
setSaving(true);
|
||||
setApiError("");
|
||||
const ok = await onSubmit(form, isNew);
|
||||
setSaving(false);
|
||||
if (ok) onClose();
|
||||
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||
};
|
||||
|
||||
const inputStyle = (field: keyof ClientAddressFormErrors): React.CSSProperties => ({
|
||||
...S.input,
|
||||
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="addr-modal-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "var(--radius-2xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "1.25rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.3em",
|
||||
textTransform: "uppercase",
|
||||
color: "#2563EB",
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{isNew ? "إضافة عنوان" : "تعديل عنوان"}
|
||||
</p>
|
||||
<h2
|
||||
id="addr-modal-title"
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{isNew ? "عنوان جديد" : editAddress?.label}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
color: "var(--color-text-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{apiError && (
|
||||
<Alert type="error" message={apiError} onClose={() => setApiError("")} />
|
||||
)}
|
||||
|
||||
{/* Label + isPrimary */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
gap: "0.75rem",
|
||||
alignItems: "end",
|
||||
}}
|
||||
>
|
||||
<label style={S.label}>
|
||||
نوع العنوان *
|
||||
<select
|
||||
ref={firstRef}
|
||||
style={{ ...inputStyle("label"), cursor: "pointer" }}
|
||||
value={form.label}
|
||||
onChange={set("label")}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر النوع</option>
|
||||
{LABEL_PRESETS.map((l) => (
|
||||
<option key={l} value={l}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.label && <span style={S.errorText}>{errors.label}</span>}
|
||||
</label>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
paddingBottom: 2,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isPrimary}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, isPrimary: e.target.checked }))
|
||||
}
|
||||
style={{ width: 14, height: 14, cursor: "pointer" }}
|
||||
/>
|
||||
عنوان رئيسي
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Street */}
|
||||
<label style={S.label}>
|
||||
الشارع / العنوان التفصيلي *
|
||||
<input
|
||||
style={inputStyle("street")}
|
||||
value={form.street}
|
||||
onChange={set("street")}
|
||||
placeholder="شارع الملك فهد، مبنى 12"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.street && <span style={S.errorText}>{errors.street}</span>}
|
||||
</label>
|
||||
|
||||
{/* City + State */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
المدينة *
|
||||
<input
|
||||
style={inputStyle("city")}
|
||||
value={form.city}
|
||||
onChange={set("city")}
|
||||
placeholder="الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.city && <span style={S.errorText}>{errors.city}</span>}
|
||||
</label>
|
||||
<label style={S.label}>
|
||||
المنطقة *
|
||||
<input
|
||||
style={inputStyle("state")}
|
||||
value={form.state}
|
||||
onChange={set("state")}
|
||||
placeholder="منطقة الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.state && <span style={S.errorText}>{errors.state}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Postal code + Country */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
الرمز البريدي *
|
||||
<input
|
||||
style={inputStyle("postalCode")}
|
||||
value={form.postalCode}
|
||||
onChange={set("postalCode")}
|
||||
placeholder="11564"
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.postalCode && (
|
||||
<span style={S.errorText}>{errors.postalCode}</span>
|
||||
)}
|
||||
</label>
|
||||
<label style={S.label}>
|
||||
الدولة *
|
||||
<input
|
||||
style={inputStyle("country")}
|
||||
value={form.country}
|
||||
onChange={set("country")}
|
||||
placeholder="المملكة العربية السعودية"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.country && <span style={S.errorText}>{errors.country}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
justifyContent: "flex-end",
|
||||
paddingTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.25rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.5rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: saving
|
||||
? "var(--color-brand-400)"
|
||||
: "var(--color-brand-600)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{saving && <Spinner size="sm" className="text-white" />}
|
||||
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة العنوان" : "حفظ التغييرات"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
Components/Client/ClientAddressForm.tsx
Normal file
175
Components/Client/ClientAddressForm.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React, { useState, FormEvent } from "react";
|
||||
import { ClientAddress, CreateClientAddressPayload } from "../../types/client";
|
||||
import { Input, Select, Button } from "../UI";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ClientAddressFormProps {
|
||||
clientId: string;
|
||||
initialValues?: Partial<ClientAddress>;
|
||||
onSubmit: (data: CreateClientAddressPayload) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
type FormErrors = Partial<Record<keyof CreateClientAddressPayload, string>>;
|
||||
|
||||
// ─── Validation ───────────────────────────────────────────────────────────────
|
||||
|
||||
function validate(v: CreateClientAddressPayload): FormErrors {
|
||||
const e: FormErrors = {};
|
||||
if (!v.label.trim()) e.label = "Label is required.";
|
||||
if (!v.street.trim()) e.street = "Street is required.";
|
||||
if (!v.city.trim()) e.city = "City is required.";
|
||||
if (!v.state.trim()) e.state = "State / Province is required.";
|
||||
if (!v.postalCode.trim()) e.postalCode = "Postal code is required.";
|
||||
if (!v.country.trim()) e.country = "Country is required.";
|
||||
return e;
|
||||
}
|
||||
|
||||
// Common address labels – quick-pick for the user
|
||||
const LABEL_PRESETS = ["Billing", "Shipping", "Head Office", "Branch", "Warehouse", "Other"];
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ClientAddressForm({
|
||||
clientId,
|
||||
initialValues = {},
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel = "Save address",
|
||||
}: ClientAddressFormProps) {
|
||||
const [values, setValues] = useState<CreateClientAddressPayload>({
|
||||
clientId,
|
||||
label: initialValues.label ?? "",
|
||||
street: initialValues.street ?? "",
|
||||
city: initialValues.city ?? "",
|
||||
state: initialValues.state ?? "",
|
||||
postalCode: initialValues.postalCode ?? "",
|
||||
country: initialValues.country ?? "",
|
||||
isPrimary: initialValues.isPrimary ?? false,
|
||||
});
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const set =
|
||||
(field: keyof Omit<CreateClientAddressPayload, "clientId" | "isPrimary">) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setValues((v) => ({ ...v, [field]: e.target.value }));
|
||||
if (errors[field]) setErrors((er) => ({ ...er, [field]: undefined }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const errs = validate(values);
|
||||
if (Object.keys(errs).length) {
|
||||
setErrors(errs);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(values);
|
||||
} catch {
|
||||
/* parent surfaces the error */
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
|
||||
{/* Label + Primary toggle */}
|
||||
<div className="flex items-end gap-4">
|
||||
<Select
|
||||
label="Label"
|
||||
value={values.label}
|
||||
onChange={(e) => {
|
||||
setValues((v) => ({ ...v, label: e.target.value }));
|
||||
if (errors.label) setErrors((er) => ({ ...er, label: undefined }));
|
||||
}}
|
||||
error={errors.label}
|
||||
wrapperClassName="flex-1"
|
||||
>
|
||||
<option value="">Select a label…</option>
|
||||
{LABEL_PRESETS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<label className="mb-1 flex items-center gap-2 text-sm text-slate-600 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={values.isPrimary}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({ ...v, isPrimary: e.target.checked }))
|
||||
}
|
||||
className="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
Primary address
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Street */}
|
||||
<Input
|
||||
label="Street address"
|
||||
placeholder="123 Main St, Suite 4"
|
||||
value={values.street}
|
||||
onChange={set("street")}
|
||||
error={errors.street}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* City + State */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
label="City"
|
||||
placeholder="New York"
|
||||
value={values.city}
|
||||
onChange={set("city")}
|
||||
error={errors.city}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="State / Province"
|
||||
placeholder="NY"
|
||||
value={values.state}
|
||||
onChange={set("state")}
|
||||
error={errors.state}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Postal code + Country */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
label="Postal code"
|
||||
placeholder="10001"
|
||||
value={values.postalCode}
|
||||
onChange={set("postalCode")}
|
||||
error={errors.postalCode}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Country"
|
||||
placeholder="United States"
|
||||
value={values.country}
|
||||
onChange={set("country")}
|
||||
error={errors.country}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={submitting}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
135
Components/Client/ClientForm.tsx
Normal file
135
Components/Client/ClientForm.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useState, FormEvent } from "react";
|
||||
import { Client, CreateClientPayload } from "../../types/client";
|
||||
import { Input, Textarea, Button } from "../UI";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ClientFormProps {
|
||||
/** Pre-populated when editing; omit for create */
|
||||
initialValues?: Partial<Client>;
|
||||
onSubmit: (data: CreateClientPayload) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
type FormErrors = Partial<Record<keyof CreateClientPayload, string>>;
|
||||
|
||||
// ─── Validation ───────────────────────────────────────────────────────────────
|
||||
|
||||
function validate(values: CreateClientPayload): FormErrors {
|
||||
const errors: FormErrors = {};
|
||||
if (!values.name.trim()) errors.name = "Name is required.";
|
||||
if (!values.email.trim()) {
|
||||
errors.email = "Email is required.";
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
|
||||
errors.email = "Enter a valid email address.";
|
||||
}
|
||||
if (!values.phone.trim()) errors.phone = "Phone is required.";
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ClientForm({
|
||||
initialValues = {},
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel = "Save client",
|
||||
}: ClientFormProps) {
|
||||
const [values, setValues] = useState<CreateClientPayload>({
|
||||
name: initialValues.name ?? "",
|
||||
email: initialValues.email ?? "",
|
||||
phone: initialValues.phone ?? "",
|
||||
taxId: initialValues.taxId ?? "",
|
||||
notes: initialValues.notes ?? "",
|
||||
});
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Generic field updater
|
||||
const set = (field: keyof CreateClientPayload) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setValues((v) => ({ ...v, [field]: e.target.value }));
|
||||
if (errors[field]) setErrors((er) => ({ ...er, [field]: undefined }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const errs = validate(values);
|
||||
if (Object.keys(errs).length > 0) {
|
||||
setErrors(errs);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(values);
|
||||
} catch {
|
||||
// Error surfaced via parent hook's alert; keep form open
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
|
||||
{/* Row 1: Name + Email */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
label="Full name"
|
||||
placeholder="Acme Corp"
|
||||
value={values.name}
|
||||
onChange={set("name")}
|
||||
error={errors.name}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="billing@acme.com"
|
||||
value={values.email}
|
||||
onChange={set("email")}
|
||||
error={errors.email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Phone + Tax ID */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
label="Phone"
|
||||
type="tel"
|
||||
placeholder="+1 555 000 0000"
|
||||
value={values.phone}
|
||||
onChange={set("phone")}
|
||||
error={errors.phone}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Tax ID / VAT (optional)"
|
||||
placeholder="US123456789"
|
||||
value={values.taxId}
|
||||
onChange={set("taxId")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<Textarea
|
||||
label="Notes (optional)"
|
||||
placeholder="Anything worth remembering about this client…"
|
||||
value={values.notes}
|
||||
onChange={set("notes")}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={submitting}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
353
Components/Client/Clientformmodal.tsx
Normal file
353
Components/Client/Clientformmodal.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import type { Client, ClientFormData, ClientFormErrors } from "../../types/client";
|
||||
|
||||
// ── Fixed styles (same token system as UserFormModal) ──────────────────────
|
||||
const S = {
|
||||
input: {
|
||||
width: "100%",
|
||||
height: 40,
|
||||
padding: "0 0.75rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-primary)",
|
||||
outline: "none",
|
||||
fontFamily: "var(--font-sans)",
|
||||
} as React.CSSProperties,
|
||||
label: {
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
} as React.CSSProperties,
|
||||
errorText: {
|
||||
fontSize: 11,
|
||||
color: "var(--color-danger)",
|
||||
fontWeight: 500,
|
||||
} as React.CSSProperties,
|
||||
};
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────────────────
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const PHONE_RE = /^\+?[0-9]{10,15}$/;
|
||||
|
||||
function validate(data: ClientFormData): ClientFormErrors {
|
||||
const e: ClientFormErrors = {};
|
||||
if (!data.name.trim()) e.name = "اسم العميل مطلوب";
|
||||
if (!data.email.trim()) {
|
||||
e.email = "البريد الإلكتروني مطلوب";
|
||||
} else if (!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 رقم)";
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface ClientFormModalProps {
|
||||
editClient: Client | null; // null = create mode, Client = edit mode
|
||||
onClose: () => void;
|
||||
onSubmit: (data: ClientFormData, isNew: boolean) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
export function ClientFormModal({
|
||||
editClient,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: ClientFormModalProps) {
|
||||
const isNew = editClient === null;
|
||||
|
||||
const [form, setForm] = useState<ClientFormData>({
|
||||
name: editClient?.name ?? "",
|
||||
email: editClient?.email ?? "",
|
||||
phone: editClient?.phone ?? "",
|
||||
taxId: editClient?.taxId ?? "",
|
||||
notes: editClient?.notes ?? "",
|
||||
});
|
||||
const [errors, setErrors] = useState<ClientFormErrors>({});
|
||||
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 ClientFormData) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setForm((p) => ({ ...p, [field]: e.target.value }));
|
||||
if (errors[field]) setErrors((p) => ({ ...p, [field]: undefined }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const errs = validate(form);
|
||||
if (Object.keys(errs).length) { setErrors(errs); return; }
|
||||
setSaving(true);
|
||||
setApiError("");
|
||||
const ok = await onSubmit(form, isNew);
|
||||
setSaving(false);
|
||||
if (ok) onClose();
|
||||
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||
};
|
||||
|
||||
const inputStyle = (field: keyof ClientFormErrors): React.CSSProperties => ({
|
||||
...S.input,
|
||||
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="client-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="client-modal-title"
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{isNew ? "عميل جديد" : editClient?.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 */}
|
||||
<label style={S.label}>
|
||||
اسم العميل *
|
||||
<input
|
||||
ref={firstInputRef}
|
||||
style={inputStyle("name")}
|
||||
value={form.name}
|
||||
onChange={set("name")}
|
||||
placeholder="شركة لوجي فلو للتوصيل"
|
||||
autoComplete="organization"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.name && <span style={S.errorText}>{errors.name}</span>}
|
||||
</label>
|
||||
|
||||
{/* Email + Phone */}
|
||||
<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="info@company.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>
|
||||
|
||||
{/* Tax ID */}
|
||||
<label style={S.label}>
|
||||
الرقم الضريبي (اختياري)
|
||||
<input
|
||||
style={S.input}
|
||||
value={form.taxId}
|
||||
onChange={set("taxId")}
|
||||
placeholder="300xxxxxxxxx"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Notes */}
|
||||
<label style={S.label}>
|
||||
ملاحظات (اختياري)
|
||||
<textarea
|
||||
style={{
|
||||
...S.input,
|
||||
height: 80,
|
||||
padding: "0.5rem 0.75rem",
|
||||
resize: "vertical",
|
||||
}}
|
||||
value={form.notes}
|
||||
onChange={set("notes")}
|
||||
placeholder="أي معلومات إضافية عن العميل…"
|
||||
dir="rtl"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
justifyContent: "flex-end",
|
||||
paddingTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.25rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.5rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: saving
|
||||
? "var(--color-brand-400)"
|
||||
: "var(--color-brand-600)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{saving && <Spinner size="sm" className="text-white" />}
|
||||
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة العميل" : "حفظ التغييرات"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
403
Components/Client/Clienttable.tsx
Normal file
403
Components/Client/Clienttable.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../UI";
|
||||
import type { Client } from "../../types/client";
|
||||
|
||||
// ── Address count badge ────────────────────────────────────────────────────
|
||||
function AddressBadge({ count }: { count: number }) {
|
||||
const hasAddr = count > 0;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: hasAddr ? "1px solid #BFDBFE" : "1px solid var(--color-border)",
|
||||
background: hasAddr ? "#EFF6FF" : "var(--color-surface-muted)",
|
||||
padding: "0.2rem 0.625rem",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: hasAddr ? "#1D4ED8" : "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{count} {count === 1 ? "عنوان" : "عناوين"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Status badge ───────────────────────────────────────────────────────────
|
||||
function StatusBadge({ active }: { active?: boolean }) {
|
||||
const isActive = active !== false;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: isActive ? "1px solid #BBF7D0" : "1px solid #FECACA",
|
||||
background: isActive ? "#DCFCE7" : "#FEF2F2",
|
||||
padding: "0.2rem 0.625rem",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: isActive ? "#166534" : "#991B1B",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: isActive ? "#16A34A" : "#DC2626",
|
||||
}}
|
||||
/>
|
||||
{isActive ? "نشط" : "غير نشط"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Icon button (mirrors UserTable's IconBtn) ──────────────────────────────
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared card & header styles ────────────────────────────────────────────
|
||||
const cardStyle: React.CSSProperties = {
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
overflow: "hidden",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
};
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
padding: "0.75rem 1.5rem",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.2em",
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-surface-muted)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
};
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface ClientTableProps {
|
||||
clients: Client[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onEdit: (client: Client) => void;
|
||||
onDelete: (client: Client) => void;
|
||||
onAddFirst: () => void;
|
||||
onPageChange: (p: number) => void;
|
||||
/** Navigate to address management for a client */
|
||||
onManageAddresses: (client: Client) => void;
|
||||
}
|
||||
|
||||
// ── Main table ─────────────────────────────────────────────────────────────
|
||||
export function ClientTable({
|
||||
clients,
|
||||
loading,
|
||||
search,
|
||||
page,
|
||||
pages,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddFirst,
|
||||
onPageChange,
|
||||
onManageAddresses,
|
||||
}: ClientTableProps) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* Column headers */}
|
||||
<div
|
||||
dir="rtl"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
|
||||
...thStyle,
|
||||
}}
|
||||
>
|
||||
<span>العميل</span>
|
||||
<span>البريد الإلكتروني</span>
|
||||
<span>الهاتف</span>
|
||||
<span>العناوين</span>
|
||||
<span style={{ textAlign: "center" }}>تاريخ الإضافة</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
padding: "4rem 0",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<Spinner size="sm" className="text-blue-600" />
|
||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
||||
</div>
|
||||
) : clients.length === 0 ? (
|
||||
/* Empty state */
|
||||
<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 }}>
|
||||
{clients.map((c, i) => (
|
||||
<li
|
||||
key={c.id}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.5fr 1.5fr 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,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => onManageAddresses(c)}
|
||||
title={`إدارة عناوين ${c.name}`}
|
||||
>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
</p>
|
||||
{c.taxId && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{c.taxId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "#2563EB",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{c.email}
|
||||
</span>
|
||||
|
||||
{/* Phone */}
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||
{c.phone}
|
||||
</span>
|
||||
|
||||
{/* Address count */}
|
||||
<AddressBadge count={c.addresses?.length ?? 0} />
|
||||
|
||||
{/* Created at */}
|
||||
<span
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{new Date(c.createdAt).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()} // prevent row navigation
|
||||
>
|
||||
<IconBtn
|
||||
title={`تعديل ${c.name}`}
|
||||
color="#1D4ED8"
|
||||
bg="#EFF6FF"
|
||||
borderColor="#BFDBFE"
|
||||
onClick={() => onEdit(c)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
title={`حذف ${c.name}`}
|
||||
color="#DC2626"
|
||||
bg="#FEF2F2"
|
||||
borderColor="#FECACA"
|
||||
onClick={() => onDelete(c)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
171
Components/Client/Deleteconfirmmodal.tsx
Normal file
171
Components/Client/Deleteconfirmmodal.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import type { Client } from "../../types/client";
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
client: Client;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteConfirmModal({
|
||||
client,
|
||||
deleting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteConfirmModalProps) {
|
||||
// Close on Escape — identical to User/DeleteConfirmModal
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="del-client-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
background: "rgba(15,23,42,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid #FECACA",
|
||||
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
||||
padding: "2rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{/* أيقونة الحذف */}
|
||||
<div
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
margin: "0 auto",
|
||||
borderRadius: "50%",
|
||||
background: "#FEF2F2",
|
||||
border: "1px solid #FECACA",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#DC2626"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2
|
||||
id="del-client-title"
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
حذف العميل
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-muted)",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
هل أنت متأكد من حذف{" "}
|
||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||
{client.name}
|
||||
</strong>
|
||||
؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={deleting}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: deleting ? "not-allowed" : "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={deleting}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: "#DC2626",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
cursor: deleting ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
fontFamily: "var(--font-sans)",
|
||||
opacity: deleting ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{deleting && <Spinner size="sm" className="text-white" />}
|
||||
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
Components/Client/Toast.tsx
Normal file
64
Components/Client/Toast.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Notification } from "../../hooks/useClients";
|
||||
|
||||
interface ToastProps {
|
||||
notification: Notification | null;
|
||||
}
|
||||
|
||||
export function Toast({ notification }: ToastProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (notification) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>{isSuccess ? "✓" : "⚠"}</span>
|
||||
<span>{notification?.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, type FC, type FormEvent } from "react";
|
||||
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";
|
||||
|
||||
// ─── Types ───────────────────────────────────
|
||||
interface RegForm {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
interface RegErrors {
|
||||
name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
// ─── RegisterStep ────────────────────────────
|
||||
interface RegisterStepProps {
|
||||
onSuccess: (s: ClientSession) => void;
|
||||
}
|
||||
|
||||
const RegisterStep: FC<RegisterStepProps> = ({ onSuccess }) => {
|
||||
const [form, setForm] = useState<RegForm>({ name: "", email: "", phone: "", companyName: "" });
|
||||
const [errors, setErrors] = useState<RegErrors>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState("");
|
||||
|
||||
const validate = (): boolean => {
|
||||
const e: RegErrors = {};
|
||||
if (!form.name.trim())
|
||||
e.name = "الاسم الكامل مطلوب";
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
|
||||
e.email = "أدخل بريدًا إلكترونيًا صحيحًا";
|
||||
if (!/^(\+966|0)?[5][0-9]{8}$/.test(form.phone.replace(/\s/g, "")))
|
||||
e.phone = "أدخل رقم هاتف سعودي صحيح (+966 5x xxx xxxx)";
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setLoading(true);
|
||||
setApiError("");
|
||||
|
||||
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,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
};
|
||||
saveSession(session);
|
||||
onSuccess(session);
|
||||
} catch (err: unknown) {
|
||||
setApiError(err instanceof Error ? err.message : "فشل التسجيل، يرجى المحاولة مجددًا.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const set = (field: keyof RegForm) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm(p => ({ ...p, [field]: e.target.value }));
|
||||
if (errors[field as keyof RegErrors]) setErrors(p => ({ ...p, [field]: undefined }));
|
||||
};
|
||||
|
||||
return (
|
||||
<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 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>
|
||||
<p className="text-[13px] text-[#6B7280] mt-1">أدخل بياناتك للبدء مع Slash.so.</p>
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
<div className="mb-4">
|
||||
<Alert message={apiError} onClose={() => setApiError("")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
||||
<Input
|
||||
label="الاسم الكامل *"
|
||||
placeholder="أحمد الرشيدي"
|
||||
value={form.name}
|
||||
onChange={set("name")}
|
||||
error={errors.name}
|
||||
autoComplete="name"
|
||||
/>
|
||||
<Input
|
||||
label="البريد الإلكتروني *"
|
||||
type="email"
|
||||
placeholder="ahmed@company.sa"
|
||||
value={form.email}
|
||||
onChange={set("email")}
|
||||
error={errors.email}
|
||||
autoComplete="email"
|
||||
/>
|
||||
<Input
|
||||
label="رقم الجوال *"
|
||||
type="tel"
|
||||
placeholder="+966 50 123 4567"
|
||||
value={form.phone}
|
||||
onChange={set("phone")}
|
||||
error={errors.phone}
|
||||
autoComplete="tel"
|
||||
/>
|
||||
<Input
|
||||
label="اسم الشركة (اختياري)"
|
||||
placeholder="شركة لوجي فلو للتوصيل"
|
||||
value={form.companyName}
|
||||
onChange={set("companyName")}
|
||||
autoComplete="organization"
|
||||
/>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button type="submit" loading={loading} fullWidth>
|
||||
إنشاء الحساب
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterStep;
|
||||
Reference in New Issue
Block a user