update order pages and create client pages also client adresses pages
This commit is contained in:
@@ -1,392 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import type {
|
||||
ClientAddress,
|
||||
ClientAddressFormData,
|
||||
ClientAddressFormErrors,
|
||||
} from "@/src/types/client";
|
||||
|
||||
// ── Fixed styles ───────────────────────────────────────────────────────────
|
||||
const S = {
|
||||
input: {
|
||||
width: "100%",
|
||||
height: 40,
|
||||
padding: "0 0.75rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-primary)",
|
||||
outline: "none",
|
||||
fontFamily: "var(--font-sans)",
|
||||
} as React.CSSProperties,
|
||||
label: {
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
} as React.CSSProperties,
|
||||
errorText: {
|
||||
fontSize: 11,
|
||||
color: "var(--color-danger)",
|
||||
fontWeight: 500,
|
||||
} as React.CSSProperties,
|
||||
};
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────────────────
|
||||
function validate(data: ClientAddressFormData): ClientAddressFormErrors {
|
||||
const e: ClientAddressFormErrors = {};
|
||||
if (!data.label.trim()) e.label = "النوع مطلوب";
|
||||
if (!data.street.trim()) e.street = "العنوان مطلوب";
|
||||
if (!data.city.trim()) e.city = "المدينة مطلوبة";
|
||||
if (!data.state.trim()) e.state = "المنطقة مطلوبة";
|
||||
if (!data.postalCode.trim()) e.postalCode = "الرمز البريدي مطلوب";
|
||||
if (!data.country.trim()) e.country = "الدولة مطلوبة";
|
||||
return e;
|
||||
}
|
||||
|
||||
const LABEL_PRESETS = ["فوترة", "شحن", "المقر الرئيسي", "فرع", "مستودع", "أخرى"];
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface AddressFormModalProps {
|
||||
editAddress: ClientAddress | null; // null = create mode
|
||||
onClose: () => void;
|
||||
onSubmit: (data: ClientAddressFormData, isNew: boolean) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
export function AddressFormModal({
|
||||
editAddress,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: AddressFormModalProps) {
|
||||
const isNew = editAddress === null;
|
||||
|
||||
const [form, setForm] = useState<ClientAddressFormData>({
|
||||
label: editAddress?.label ?? "",
|
||||
street: editAddress?.street ?? "",
|
||||
city: editAddress?.city ?? "",
|
||||
state: editAddress?.state ?? "",
|
||||
postalCode: editAddress?.postalCode ?? "",
|
||||
country: editAddress?.country ?? "المملكة العربية السعودية",
|
||||
isPrimary: editAddress?.isPrimary ?? false,
|
||||
});
|
||||
const [errors, setErrors] = useState<ClientAddressFormErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [apiError, setApiError] = useState("");
|
||||
const firstRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
useEffect(() => { firstRef.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const set = (field: keyof ClientAddressFormData) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setForm((p) => ({ ...p, [field]: e.target.value }));
|
||||
if (errors[field as keyof ClientAddressFormErrors])
|
||||
setErrors((p) => ({ ...p, [field]: undefined }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const errs = validate(form);
|
||||
if (Object.keys(errs).length) { setErrors(errs); return; }
|
||||
setSaving(true);
|
||||
setApiError("");
|
||||
const ok = await onSubmit(form, isNew);
|
||||
setSaving(false);
|
||||
if (ok) onClose();
|
||||
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||
};
|
||||
|
||||
const inputStyle = (field: keyof ClientAddressFormErrors): React.CSSProperties => ({
|
||||
...S.input,
|
||||
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="addr-modal-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "var(--radius-2xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "1.25rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.3em",
|
||||
textTransform: "uppercase",
|
||||
color: "#2563EB",
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{isNew ? "إضافة عنوان" : "تعديل عنوان"}
|
||||
</p>
|
||||
<h2
|
||||
id="addr-modal-title"
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{isNew ? "عنوان جديد" : editAddress?.label}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
color: "var(--color-text-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{apiError && (
|
||||
<Alert type="error" message={apiError} onClose={() => setApiError("")} />
|
||||
)}
|
||||
|
||||
{/* Label + isPrimary */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
gap: "0.75rem",
|
||||
alignItems: "end",
|
||||
}}
|
||||
>
|
||||
<label style={S.label}>
|
||||
نوع العنوان *
|
||||
<select
|
||||
ref={firstRef}
|
||||
style={{ ...inputStyle("label"), cursor: "pointer" }}
|
||||
value={form.label}
|
||||
onChange={set("label")}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر النوع</option>
|
||||
{LABEL_PRESETS.map((l) => (
|
||||
<option key={l} value={l}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.label && <span style={S.errorText}>{errors.label}</span>}
|
||||
</label>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
paddingBottom: 2,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isPrimary}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, isPrimary: e.target.checked }))
|
||||
}
|
||||
style={{ width: 14, height: 14, cursor: "pointer" }}
|
||||
/>
|
||||
عنوان رئيسي
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Street */}
|
||||
<label style={S.label}>
|
||||
الشارع / العنوان التفصيلي *
|
||||
<input
|
||||
style={inputStyle("street")}
|
||||
value={form.street}
|
||||
onChange={set("street")}
|
||||
placeholder="شارع الملك فهد، مبنى 12"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.street && <span style={S.errorText}>{errors.street}</span>}
|
||||
</label>
|
||||
|
||||
{/* City + State */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
المدينة *
|
||||
<input
|
||||
style={inputStyle("city")}
|
||||
value={form.city}
|
||||
onChange={set("city")}
|
||||
placeholder="الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.city && <span style={S.errorText}>{errors.city}</span>}
|
||||
</label>
|
||||
<label style={S.label}>
|
||||
المنطقة *
|
||||
<input
|
||||
style={inputStyle("state")}
|
||||
value={form.state}
|
||||
onChange={set("state")}
|
||||
placeholder="منطقة الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.state && <span style={S.errorText}>{errors.state}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Postal code + Country */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
الرمز البريدي *
|
||||
<input
|
||||
style={inputStyle("postalCode")}
|
||||
value={form.postalCode}
|
||||
onChange={set("postalCode")}
|
||||
placeholder="11564"
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.postalCode && (
|
||||
<span style={S.errorText}>{errors.postalCode}</span>
|
||||
)}
|
||||
</label>
|
||||
<label style={S.label}>
|
||||
الدولة *
|
||||
<input
|
||||
style={inputStyle("country")}
|
||||
value={form.country}
|
||||
onChange={set("country")}
|
||||
placeholder="المملكة العربية السعودية"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.country && <span style={S.errorText}>{errors.country}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
justifyContent: "flex-end",
|
||||
paddingTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.25rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.5rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: saving
|
||||
? "var(--color-brand-400)"
|
||||
: "var(--color-brand-600)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{saving && <Spinner size="sm" className="text-white" />}
|
||||
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة العنوان" : "حفظ التغييرات"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import type { Client, ClientFormData, ClientFormErrors } from "@/src/types/client";
|
||||
import {
|
||||
createClientSchema,
|
||||
updateClientSchema,
|
||||
CLIENT_TYPES,
|
||||
type CreateClientFormValues,
|
||||
type UpdateClientFormValues,
|
||||
} from "@/src/validations/client.validator";
|
||||
import type { Client } from "@/src/types/client";
|
||||
|
||||
// ── Fixed styles (same token system as UserFormModal) ──────────────────────
|
||||
// ── Styles ─────────────────────────────────────────────────────────────────
|
||||
const S = {
|
||||
input: {
|
||||
width: "100%",
|
||||
@@ -33,83 +42,58 @@ const S = {
|
||||
} 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;
|
||||
}
|
||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
||||
...S.input,
|
||||
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface ClientFormModalProps {
|
||||
editClient: Client | null; // null = create mode, Client = edit mode
|
||||
editClient: Client | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: ClientFormData, isNew: boolean) => Promise<boolean>;
|
||||
onSubmit: (
|
||||
data: CreateClientFormValues | UpdateClientFormValues,
|
||||
isNew: boolean
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
export function ClientFormModal({
|
||||
editClient,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: ClientFormModalProps) {
|
||||
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);
|
||||
// FIX 1: was `Schema` (capital S) — now correctly `schema`
|
||||
const schema = isNew ? createClientSchema : updateClientSchema;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateClientFormValues | UpdateClientFormValues>({
|
||||
resolver: yupResolver(schema) as never, // FIX 1 applied here
|
||||
defaultValues: {
|
||||
name: editClient?.name ?? "",
|
||||
email: editClient?.email ?? "",
|
||||
phone: editClient?.phone ?? "",
|
||||
clientType: editClient?.clientType ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
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 submitHandler = async (data: CreateClientFormValues | UpdateClientFormValues) => {
|
||||
const ok = await onSubmit(data, isNew);
|
||||
if (ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
||||
}
|
||||
};
|
||||
|
||||
const inputStyle = (field: keyof ClientFormErrors): React.CSSProperties => ({
|
||||
...S.input,
|
||||
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
@@ -200,7 +184,7 @@ export function ClientFormModal({
|
||||
|
||||
{/* Body */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
@@ -209,26 +193,24 @@ export function ClientFormModal({
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{apiError && (
|
||||
<Alert type="error" message={apiError} onClose={() => setApiError("")} />
|
||||
{errors.name?.type === "manual" && (
|
||||
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<label style={S.label}>
|
||||
اسم العميل *
|
||||
اسم العميل {isNew && "*"}
|
||||
<input
|
||||
ref={firstInputRef}
|
||||
style={inputStyle("name")}
|
||||
value={form.name}
|
||||
onChange={set("name")}
|
||||
{...register("name")}
|
||||
style={withError(!!errors.name)}
|
||||
placeholder="شركة لوجي فلو للتوصيل"
|
||||
autoComplete="organization"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.name && <span style={S.errorText}>{errors.name}</span>}
|
||||
{errors.name && errors.name.type !== "manual" && (
|
||||
<span style={S.errorText}>{errors.name.message}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Email + Phone */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -237,63 +219,77 @@ export function ClientFormModal({
|
||||
}}
|
||||
>
|
||||
<label style={S.label}>
|
||||
البريد الإلكتروني *
|
||||
البريد الإلكتروني
|
||||
<input
|
||||
style={inputStyle("email")}
|
||||
{...register("email")}
|
||||
style={withError(!!errors.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>}
|
||||
{errors.email && (
|
||||
<span style={S.errorText}>{errors.email.message}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label style={S.label}>
|
||||
رقم الهاتف *
|
||||
رقم الهاتف {isNew && "*"}
|
||||
<input
|
||||
style={inputStyle("phone")}
|
||||
{...register("phone")}
|
||||
style={withError(!!errors.phone)}
|
||||
type="tel"
|
||||
value={form.phone}
|
||||
onChange={set("phone")}
|
||||
placeholder="+966 5x xxx xxxx"
|
||||
placeholder="05xxxxxxxx"
|
||||
autoComplete="tel"
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.phone && <span style={S.errorText}>{errors.phone}</span>}
|
||||
{errors.phone && (
|
||||
<span style={S.errorText}>{errors.phone.message}</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="أي معلومات إضافية عن العميل…"
|
||||
نوع العميل
|
||||
<select
|
||||
{...register("clientType")}
|
||||
style={{ ...withError(!!errors.clientType), cursor: "pointer" }}
|
||||
dir="rtl"
|
||||
/>
|
||||
>
|
||||
<option value="">اختر النوع</option>
|
||||
{CLIENT_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t === "Individual" ? "فرد" : "شركة"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.clientType && (
|
||||
<span style={S.errorText}>{errors.clientType.message}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Actions */}
|
||||
{!isNew && (
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("isActive" as never)}
|
||||
defaultChecked={editClient?.isActive ?? true}
|
||||
style={{ width: 14, height: 14, cursor: "pointer" }}
|
||||
/>
|
||||
عميل نشط
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -305,7 +301,7 @@ export function ClientFormModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.25rem",
|
||||
@@ -315,7 +311,7 @@ export function ClientFormModal({
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
@@ -323,27 +319,27 @@ export function ClientFormModal({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.5rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: saving
|
||||
background: isSubmitting
|
||||
? "var(--color-brand-400)"
|
||||
: "var(--color-brand-600)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{saving && <Spinner size="sm" className="text-white" />}
|
||||
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة العميل" : "حفظ التغييرات"}
|
||||
{isSubmitting && <Spinner size="sm" className="text-white" />}
|
||||
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة العميل" : "حفظ التغييرات"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ClientTable — Enhanced version
|
||||
*
|
||||
* Changes from original:
|
||||
* 1. Clicking a row now opens an inline ClientDetailPanel (slide-down) showing full client info.
|
||||
* 2. "Addresses" link inside the detail panel navigates to /dashboard/clients/[clientId]/addresses.
|
||||
* 3. The row-level onClick still calls onManageAddresses (kept for backwards-compatibility),
|
||||
* but the primary UX is now the expandable detail panel.
|
||||
* 4. Active row is highlighted with a brand-tinted left border.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import type { Client } from "@/src/types/client";
|
||||
|
||||
// ── Address count badge ────────────────────────────────────────────────────
|
||||
function AddressBadge({ count }: { count: number }) {
|
||||
function AddressBadge({ count, onClick }: { count: number; onClick: () => void }) {
|
||||
const hasAddr = count > 0;
|
||||
return (
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
title="إدارة العناوين"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: hasAddr ? "1px solid #BFDBFE" : "1px solid var(--color-border)",
|
||||
background: hasAddr ? "#EFF6FF" : "var(--color-surface-muted)",
|
||||
@@ -18,10 +34,17 @@ function AddressBadge({ count }: { count: number }) {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: hasAddr ? "#1D4ED8" : "var(--color-text-muted)",
|
||||
cursor: "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
transition: "opacity 150ms",
|
||||
}}
|
||||
>
|
||||
{count} {count === 1 ? "عنوان" : "عناوين"}
|
||||
</span>
|
||||
{/* Small arrow icon to hint navigation */}
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +79,7 @@ function StatusBadge({ active }: { active?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Icon button (mirrors UserTable's IconBtn) ──────────────────────────────
|
||||
// ── Icon button ────────────────────────────────────────────────────────────
|
||||
function IconBtn({
|
||||
onClick,
|
||||
title,
|
||||
@@ -97,6 +120,233 @@ function IconBtn({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Client detail panel (inline expandable) ────────────────────────────────
|
||||
/**
|
||||
* ClientDetailPanel — shown below the clicked row.
|
||||
* Displays all client fields (name, email, phone, taxId, notes, createdAt)
|
||||
* and provides a direct link to the addresses sub-page.
|
||||
*
|
||||
* @param client The client whose details are rendered.
|
||||
* @param onGoAddresses Callback that navigates to /dashboard/clients/[id]/addresses.
|
||||
* @param onEdit Opens the edit modal for this client.
|
||||
*/
|
||||
function ClientDetailPanel({
|
||||
client,
|
||||
onGoAddresses,
|
||||
onEdit,
|
||||
}: {
|
||||
client: Client;
|
||||
onGoAddresses: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const infoItem = (label: string, value: React.ReactNode) => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.15em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-primary)",
|
||||
fontWeight: 500,
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{value || <span style={{ color: "var(--color-text-hint)" }}>—</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
style={{
|
||||
/* Slide-down visual treatment: left accent border in brand blue */
|
||||
borderRight: "3px solid var(--color-brand-600)",
|
||||
background: "linear-gradient(135deg, #EFF6FF 0%, #F0F9FF 100%)",
|
||||
borderBottom: "1px solid #BFDBFE",
|
||||
padding: "1.25rem 1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
animation: "detailFadeIn 180ms ease",
|
||||
}}
|
||||
>
|
||||
{/* ── Top row: name + quick actions ── */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: "1rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
{client.name}
|
||||
</h3>
|
||||
{client.taxId && (
|
||||
<code
|
||||
style={{
|
||||
marginTop: 2,
|
||||
display: "block",
|
||||
fontSize: 11,
|
||||
background: "#DBEAFE",
|
||||
border: "1px solid #BFDBFE",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
padding: "0.1rem 0.4rem",
|
||||
color: "#1E40AF",
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
{client.taxId}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexShrink: 0 }}>
|
||||
{/* Edit client */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||
style={{
|
||||
height: 32,
|
||||
padding: "0 0.875rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid #BFDBFE",
|
||||
background: "#EFF6FF",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "#1D4ED8",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
تعديل البيانات
|
||||
</button>
|
||||
|
||||
{/*
|
||||
* "Addresses" button — KEY FEATURE:
|
||||
* Navigates to /dashboard/clients/[clientId]/addresses
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onGoAddresses(); }}
|
||||
style={{
|
||||
height: 32,
|
||||
padding: "0 0.875rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: "var(--color-brand-600)",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontFamily: "var(--font-sans)",
|
||||
boxShadow: "0 1px 4px rgba(37,99,235,.3)",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
العناوين
|
||||
{client.addresses?.length !== undefined && (
|
||||
<span
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.25)",
|
||||
borderRadius: "var(--radius-full)",
|
||||
padding: "0 5px",
|
||||
fontSize: 10,
|
||||
minWidth: 16,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{client.addresses.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Info grid ── */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
|
||||
gap: "1rem",
|
||||
paddingTop: "0.75rem",
|
||||
borderTop: "1px solid #BFDBFE",
|
||||
}}
|
||||
>
|
||||
{infoItem(
|
||||
"البريد الإلكتروني",
|
||||
<a
|
||||
href={`mailto:${client.email}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ color: "#2563EB", textDecoration: "none" }}
|
||||
>
|
||||
{client.email}
|
||||
</a>,
|
||||
)}
|
||||
{infoItem("الهاتف", client.phone)}
|
||||
{infoItem(
|
||||
"تاريخ الإضافة",
|
||||
new Date(client.createdAt).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
)}
|
||||
{infoItem(
|
||||
"آخر تحديث",
|
||||
new Date(client.updatedAt).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
)}
|
||||
{client.notes && infoItem("ملاحظات", client.notes)}
|
||||
</div>
|
||||
|
||||
{/* Keyframe animation injected via a style tag (scoped) */}
|
||||
<style>{`
|
||||
@keyframes detailFadeIn {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared card & header styles ────────────────────────────────────────────
|
||||
const cardStyle: React.CSSProperties = {
|
||||
borderRadius: "var(--radius-xl)",
|
||||
@@ -119,14 +369,14 @@ const thStyle: React.CSSProperties = {
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface ClientTableProps {
|
||||
clients: Client[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onEdit: (client: Client) => void;
|
||||
onDelete: (client: Client) => void;
|
||||
onAddFirst: () => void;
|
||||
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;
|
||||
@@ -145,6 +395,17 @@ export function ClientTable({
|
||||
onPageChange,
|
||||
onManageAddresses,
|
||||
}: ClientTableProps) {
|
||||
/**
|
||||
* expandedId — tracks which client row is currently expanded.
|
||||
* Clicking the same row again collapses it (toggle behaviour).
|
||||
*/
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const handleRowClick = (client: Client) => {
|
||||
// Toggle: collapse if already open, expand otherwise
|
||||
setExpandedId((prev) => (prev === client.id ? null : client.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* Column headers */}
|
||||
@@ -209,138 +470,202 @@ export function ClientTable({
|
||||
) : (
|
||||
/* 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
|
||||
{clients.map((c, i) => {
|
||||
const isExpanded = expandedId === c.id;
|
||||
return (
|
||||
<li key={c.id}>
|
||||
{/* ── Main row ── */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${c.name} — انقر لعرض التفاصيل`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleRowClick(c);
|
||||
}}
|
||||
onClick={() => handleRowClick(c)}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: 0,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: isExpanded
|
||||
? "none"
|
||||
: "1px solid var(--color-border)",
|
||||
/* Highlight the expanded row with a brand-tinted background */
|
||||
background: isExpanded
|
||||
? "#EFF6FF"
|
||||
: i % 2 !== 0
|
||||
? "var(--color-surface-muted)"
|
||||
: "transparent",
|
||||
/* Left accent border when expanded (RTL: border-right in visual) */
|
||||
borderRight: isExpanded
|
||||
? "3px solid var(--color-brand-600)"
|
||||
: "3px solid transparent",
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
transition: "background 120ms, border-color 120ms",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
</p>
|
||||
{c.taxId && (
|
||||
<p
|
||||
{/* Name */}
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
{/* Chevron indicator — rotates when expanded */}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--color-text-muted)",
|
||||
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
||||
transition: "transform 200ms",
|
||||
}}
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</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={{
|
||||
marginTop: 2,
|
||||
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 badge — clicking it navigates directly to
|
||||
* /dashboard/clients/[clientId]/addresses (bypasses the detail panel).
|
||||
*/}
|
||||
<AddressBadge
|
||||
count={c.addresses?.length ?? 0}
|
||||
onClick={() => onManageAddresses(c)}
|
||||
/>
|
||||
|
||||
{/* Created at */}
|
||||
<span
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{c.taxId}
|
||||
</p>
|
||||
{new Date(c.createdAt).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
|
||||
{/* Row action buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* ── Expandable detail panel ──
|
||||
* Rendered inline below the row when isExpanded is true.
|
||||
* Contains full client metadata + "العناوين" navigation button.
|
||||
*
|
||||
* TEST: Click any row → panel slides in with client details.
|
||||
* Click "العناوين" → router.push to /dashboard/clients/[id]/addresses.
|
||||
* Click same row again → panel collapses.
|
||||
*/}
|
||||
{isExpanded && (
|
||||
<ClientDetailPanel
|
||||
client={c}
|
||||
onGoAddresses={() => onManageAddresses(c)}
|
||||
onEdit={() => onEdit(c)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,64 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Notification } from "@/src/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>
|
||||
);
|
||||
}
|
||||
// Re-export the canonical implementation so existing imports keep working.
|
||||
export { Toast } from "@/src/Components/UI";
|
||||
export type { ToastNotification as Notification } from "@/src/Components/UI";
|
||||
@@ -1,5 +1,18 @@
|
||||
export { ClientFormModal } from "./Clientformmodal";
|
||||
export { ClientTable } from "./Clienttable";
|
||||
export { AddressFormModal } from "./Addressformmodal";
|
||||
/**
|
||||
* src/Components/Client/index.ts
|
||||
*
|
||||
* Barrel exports for all Client-domain components.
|
||||
*
|
||||
* NOTE: Toast is re-exported here for backwards-compatibility, but it now
|
||||
* delegates to src/Components/UI/Toast. Prefer importing Toast directly
|
||||
* from "@/src/Components/UI" in new code.
|
||||
*/
|
||||
|
||||
export { ClientFormModal } from "./Clientformmodal";
|
||||
export { ClientTable } from "./Clienttable";
|
||||
export { AddressFormModal } from "../Client_Adress/Addressformmodal";
|
||||
export { DeleteConfirmModal } from "./Deleteconfirmmodal";
|
||||
|
||||
// Toast: re-exported from Client/Toast, which itself re-exports UI/Toast.
|
||||
// Maintains backwards-compat for any consumer importing from "@/src/Components/Client".
|
||||
export { Toast } from "./Toast";
|
||||
556
src/Components/Client_Adress/Addressformmodal.tsx
Normal file
556
src/Components/Client_Adress/Addressformmodal.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import {
|
||||
createAddressSchema,
|
||||
updateAddressSchema,
|
||||
type CreateAddressFormValues,
|
||||
type UpdateAddressFormValues,
|
||||
} from "@/src/validations/client_address.validator";
|
||||
import type { ClientAddress } from "@/src/types/client";
|
||||
|
||||
// ── 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,
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-muted)",
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase" as const,
|
||||
paddingBottom: "0.25rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
} as React.CSSProperties,
|
||||
};
|
||||
|
||||
// FIX 2: Use only the `border` shorthand in both branches — no borderColor/border conflict
|
||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
||||
...S.input,
|
||||
border: hasError
|
||||
? "1px solid var(--color-danger)"
|
||||
: "1px solid var(--color-border)",
|
||||
background: hasError ? "#FEF2F2" : S.input.background,
|
||||
});
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface AddressFormModalProps {
|
||||
editAddress: ClientAddress | null;
|
||||
onClose: () => void;
|
||||
// FIX: removed the "isNew" second argument. The parent (page.tsx) now
|
||||
// figures out create-vs-update itself from addrFormTarget, so it no longer
|
||||
// needs us to tell it isNew. This avoids the two values (isNew here vs
|
||||
// addrFormTarget there) ever disagreeing with each other.
|
||||
onSubmit: (
|
||||
data: CreateAddressFormValues | UpdateAddressFormValues
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressFormModalProps) {
|
||||
const isNew = editAddress === null;
|
||||
|
||||
// NOTE: "schema" variable removed — resolver now picks createAddressSchema
|
||||
// or updateAddressSchema directly inline (see useForm below).
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateAddressFormValues | UpdateAddressFormValues>({
|
||||
// FIX: "as never" was hiding all type errors on resolver/errors.
|
||||
// yupResolver only works with ONE concrete schema type, not a union,
|
||||
// so we still need a cast — but cast to the real Resolver type instead
|
||||
// of "never", so formState.errors keeps proper typing.
|
||||
resolver: (isNew ? yupResolver(createAddressSchema) : yupResolver(updateAddressSchema)) as Resolver<
|
||||
CreateAddressFormValues | UpdateAddressFormValues
|
||||
>,
|
||||
defaultValues: {
|
||||
label: editAddress?.label ?? "",
|
||||
branchName: editAddress?.branchName ?? "",
|
||||
isPrimary: editAddress?.isPrimary ?? false,
|
||||
|
||||
contactPerson: {
|
||||
name: editAddress?.contactPerson?.name ?? "",
|
||||
phone: editAddress?.contactPerson?.phone ?? "",
|
||||
},
|
||||
|
||||
details: {
|
||||
country: editAddress?.details?.country ?? "SA",
|
||||
city: editAddress?.details?.city ?? "",
|
||||
state: editAddress?.details?.state ?? "",
|
||||
district: editAddress?.details?.district ?? "",
|
||||
street: editAddress?.details?.street ?? "",
|
||||
buildingNo: editAddress?.details?.buildingNo ?? "",
|
||||
unitNo: editAddress?.details?.unitNo ?? "",
|
||||
additionalNo: editAddress?.details?.additionalNo ?? "",
|
||||
zipCode: editAddress?.details?.zipCode ?? "",
|
||||
apartment: editAddress?.details?.apartment ?? "",
|
||||
},
|
||||
|
||||
location: {
|
||||
coordinates: editAddress?.location?.coordinates ?? [0, 0],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const detailsErr = (errors as never as Record<string, Record<string, { message?: string }>>)?.details ?? {};
|
||||
const contactErr = (errors as never as Record<string, Record<string, { message?: string }>>)?.contactPerson ?? {};
|
||||
const locationErr = (errors as never as Record<string, Record<string, { message?: string }>>)?.location ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const submitHandler = async (data: CreateAddressFormValues | UpdateAddressFormValues) => {
|
||||
const ok = await onSubmit(data); // FIX: no more isNew arg, see prop type above
|
||||
if (ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setError("label", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
||||
}
|
||||
};
|
||||
|
||||
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: 560,
|
||||
maxHeight: "90vh",
|
||||
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",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Header — pinned */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
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>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||
<form
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{errors.label?.type === "manual" && (
|
||||
<Alert type="error" message={errors.label.message ?? ""} onClose={() => {}} />
|
||||
)}
|
||||
|
||||
{/* Address Meta */}
|
||||
<p style={S.sectionTitle}>بيانات العنوان</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
gap: "0.75rem",
|
||||
alignItems: "end",
|
||||
}}
|
||||
>
|
||||
{/* FIX 3: Replaced <select> with a free-text <input> */}
|
||||
<label style={S.label}>
|
||||
نوع العنوان
|
||||
<input
|
||||
{...register("label")}
|
||||
style={withError(!!errors.label && errors.label.type !== "manual")}
|
||||
placeholder="مقترح: منزل / مكتب"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.label && errors.label.type !== "manual" && (
|
||||
<span style={S.errorText}>{errors.label.message}</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"
|
||||
{...register("isPrimary")}
|
||||
style={{ width: 14, height: 14, cursor: "pointer" }}
|
||||
/>
|
||||
عنوان رئيسي
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={S.label}>
|
||||
اسم الفرع (اختياري)
|
||||
<input
|
||||
{...register("branchName")}
|
||||
style={S.input}
|
||||
placeholder="فرع الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Address Details */}
|
||||
<p style={S.sectionTitle}>تفاصيل العنوان</p>
|
||||
|
||||
<label style={S.label}>
|
||||
الشارع / العنوان التفصيلي {isNew && "*"}
|
||||
<input
|
||||
{...register("details.street")}
|
||||
style={withError(!!detailsErr.street)}
|
||||
placeholder="شارع الملك فهد، مبنى 12"
|
||||
dir="rtl"
|
||||
/>
|
||||
{detailsErr.street && (
|
||||
<span style={S.errorText}>{detailsErr.street.message}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
المدينة {isNew && "*"}
|
||||
<input
|
||||
{...register("details.city")}
|
||||
style={withError(!!detailsErr.city)}
|
||||
placeholder="الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
{detailsErr.city && (
|
||||
<span style={S.errorText}>{detailsErr.city.message}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label style={S.label}>
|
||||
المنطقة
|
||||
<input
|
||||
{...register("details.state")}
|
||||
style={withError(!!detailsErr.state)}
|
||||
placeholder="منطقة الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
{detailsErr.state && (
|
||||
<span style={S.errorText}>{detailsErr.state.message}</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
الحي (اختياري)
|
||||
<input
|
||||
{...register("details.district")}
|
||||
style={S.input}
|
||||
placeholder="حي العليا"
|
||||
dir="rtl"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={S.label}>
|
||||
رقم المبنى (اختياري)
|
||||
<input
|
||||
{...register("details.buildingNo")}
|
||||
style={S.input}
|
||||
placeholder="1234"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
رقم الوحدة (اختياري)
|
||||
<input
|
||||
{...register("details.unitNo")}
|
||||
style={S.input}
|
||||
placeholder="5678"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={S.label}>
|
||||
الرقم الإضافي (اختياري)
|
||||
<input
|
||||
{...register("details.additionalNo")}
|
||||
style={S.input}
|
||||
placeholder="0000"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
الرمز البريدي (اختياري)
|
||||
<input
|
||||
{...register("details.zipCode")}
|
||||
style={withError(!!detailsErr.zipCode)}
|
||||
placeholder="11564"
|
||||
dir="ltr"
|
||||
/>
|
||||
{detailsErr.zipCode && (
|
||||
<span style={S.errorText}>{detailsErr.zipCode.message}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label style={S.label}>
|
||||
الدولة
|
||||
<input
|
||||
{...register("details.country")}
|
||||
style={withError(!!detailsErr.country)}
|
||||
placeholder="SA"
|
||||
dir="ltr"
|
||||
/>
|
||||
{detailsErr.country && (
|
||||
<span style={S.errorText}>{detailsErr.country.message}</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={S.label}>
|
||||
الشقة / الطابق (اختياري)
|
||||
<input
|
||||
{...register("details.apartment")}
|
||||
style={S.input}
|
||||
placeholder="الطابق الثالث"
|
||||
dir="rtl"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Contact Person */}
|
||||
<p style={S.sectionTitle}>جهة الاتصال (اختياري)</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
الاسم
|
||||
<input
|
||||
{...register("contactPerson.name")}
|
||||
style={withError(!!contactErr.name)}
|
||||
placeholder="أحمد محمد"
|
||||
dir="rtl"
|
||||
/>
|
||||
{contactErr.name && (
|
||||
<span style={S.errorText}>{contactErr.name.message}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label style={S.label}>
|
||||
رقم الهاتف
|
||||
<input
|
||||
{...register("contactPerson.phone")}
|
||||
style={withError(!!contactErr.phone)}
|
||||
type="tel"
|
||||
placeholder="05xxxxxxxx"
|
||||
dir="ltr"
|
||||
/>
|
||||
{contactErr.phone && (
|
||||
<span style={S.errorText}>{contactErr.phone.message}</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Coordinates */}
|
||||
<p style={S.sectionTitle}>الإحداثيات الجغرافية {isNew && "*"}</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
خط الطول (Longitude) {isNew && "*"}
|
||||
<input
|
||||
{...register("location.coordinates.0" as never)}
|
||||
style={withError(!!locationErr.coordinates)}
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="46.6753"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={S.label}>
|
||||
خط العرض (Latitude) {isNew && "*"}
|
||||
<input
|
||||
{...register("location.coordinates.1" as never)}
|
||||
style={withError(!!locationErr.coordinates)}
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="24.7136"
|
||||
dir="ltr"
|
||||
/>
|
||||
{locationErr.coordinates && (
|
||||
<span style={S.errorText}>{locationErr.coordinates.message}</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
justifyContent: "flex-end",
|
||||
paddingTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
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: isSubmitting ? "not-allowed" : "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.5rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: isSubmitting
|
||||
? "var(--color-brand-400)"
|
||||
: "var(--color-brand-600)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{isSubmitting && <Spinner size="sm" className="text-white" />}
|
||||
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة العنوان" : "حفظ التغييرات"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { clientService } from "@/src/services/client.service";
|
||||
import { tripService } from "@/src/services/trip.service";
|
||||
import { createOrderSchema, updateOrderSchema } from "@/src/validations/order.validation";
|
||||
import { tripService } from "@/src/services/trip.service";
|
||||
import {
|
||||
createOrderSchema,
|
||||
updateOrderSchema,
|
||||
} from "@/src/validations/order.validation";
|
||||
import type { ValidationError } from "yup";
|
||||
import type {
|
||||
Order,
|
||||
@@ -17,7 +19,7 @@ import type {
|
||||
PaymentStatus,
|
||||
} from "@/src/types/order";
|
||||
import type { Client } from "@/src/types/client";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
import type { OrderSchemaErrors } from "@/src/validations/order.validation";
|
||||
|
||||
// ── Shared input / label styles — verbatim from DriverFormModal.tsx ──────────
|
||||
@@ -91,84 +93,119 @@ interface OrderFormModalProps {
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalProps) {
|
||||
export function OrderFormModal({
|
||||
editOrder,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: OrderFormModalProps) {
|
||||
const isNew = editOrder === null;
|
||||
|
||||
// ── Relation data — clients & trips loaded once on mount ─────────────────
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [trips, setTrips] = useState<Trip[]>([]);
|
||||
const [relLoading, setRelLoading] = useState(true);
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [trips, setTrips] = useState<Trip[]>([]);
|
||||
const [relLoading, setRelLoading] = useState(true);
|
||||
|
||||
// ── Form state ────────────────────────────────────────────────────────────
|
||||
const [shipmentNumber, setShipmentNumber] = useState(editOrder?.shipmentNumber ?? "");
|
||||
const [recipientName, setRecipientName] = useState(editOrder?.recipientName ?? "");
|
||||
const [recipientPhone, setRecipientPhone] = useState(editOrder?.recipientPhone ?? "");
|
||||
const [clientId, setClientId] = useState(editOrder?.clientId ?? "");
|
||||
const [tripId, setTripId] = useState(editOrder?.tripId ?? "");
|
||||
const [type, setType] = useState(editOrder?.type ?? "");
|
||||
const [quantity, setQuantity] = useState(String(editOrder?.quantity ?? 1));
|
||||
const [weight, setWeight] = useState(
|
||||
const [shipmentNumber, setShipmentNumber] = useState(
|
||||
editOrder?.shipmentNumber ?? "",
|
||||
);
|
||||
const [recipientName, setRecipientName] = useState(
|
||||
editOrder?.recipientName ?? "",
|
||||
);
|
||||
const [recipientPhone, setRecipientPhone] = useState(
|
||||
editOrder?.recipientPhone ?? "",
|
||||
);
|
||||
const [clientId, setClientId] = useState(editOrder?.clientId ?? "");
|
||||
const [tripId, setTripId] = useState(editOrder?.tripId ?? "");
|
||||
const [type, setType] = useState(editOrder?.type ?? "");
|
||||
const [quantity, setQuantity] = useState(String(editOrder?.quantity ?? 1));
|
||||
const [weight, setWeight] = useState(
|
||||
editOrder?.weight != null ? String(editOrder.weight) : "",
|
||||
);
|
||||
const [subTotal, setSubTotal] = useState(
|
||||
const [subTotal, setSubTotal] = useState(
|
||||
editOrder?.subTotal != null ? String(editOrder.subTotal) : "",
|
||||
);
|
||||
const [vatRate, setVatRate] = useState(
|
||||
const [vatRate, setVatRate] = useState(
|
||||
editOrder?.vatRate != null ? String(editOrder.vatRate) : "",
|
||||
);
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod | "">(
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod | "">(
|
||||
editOrder?.paymentMethod ?? "",
|
||||
);
|
||||
const [paymentStatus, setPaymentStatus] = useState<PaymentStatus | "">(
|
||||
const [paymentStatus, setPaymentStatus] = useState<PaymentStatus | "">(
|
||||
editOrder?.paymentStatus ?? "",
|
||||
);
|
||||
|
||||
// ── Address state ─────────────────────────────────────────────────────────
|
||||
// Delivery address (عنوان التسليم) — always creates new MongoDB doc
|
||||
const [delCity, setDelCity] = useState(editOrder?.deliveryAddress?.details?.city ?? "");
|
||||
const [delDistrict, setDelDistrict] = useState(editOrder?.deliveryAddress?.details?.district ?? "");
|
||||
const [delStreet, setDelStreet] = useState(editOrder?.deliveryAddress?.details?.street ?? "");
|
||||
const [delBuildingNo, setDelBuildingNo] = useState(editOrder?.deliveryAddress?.details?.buildingNo ?? "");
|
||||
const [delUnitNo, setDelUnitNo] = useState(editOrder?.deliveryAddress?.details?.unitNo ?? "");
|
||||
const [delZipCode, setDelZipCode] = useState(editOrder?.deliveryAddress?.details?.zipCode ?? "");
|
||||
const [delCity, setDelCity] = useState(
|
||||
editOrder?.deliveryAddress?.details?.city ?? "",
|
||||
);
|
||||
const [delDistrict, setDelDistrict] = useState(
|
||||
editOrder?.deliveryAddress?.details?.district ?? "",
|
||||
);
|
||||
const [delStreet, setDelStreet] = useState(
|
||||
editOrder?.deliveryAddress?.details?.street ?? "",
|
||||
);
|
||||
const [delBuildingNo, setDelBuildingNo] = useState(
|
||||
editOrder?.deliveryAddress?.details?.buildingNo ?? "",
|
||||
);
|
||||
const [delUnitNo, setDelUnitNo] = useState(
|
||||
editOrder?.deliveryAddress?.details?.unitNo ?? "",
|
||||
);
|
||||
const [delZipCode, setDelZipCode] = useState(
|
||||
editOrder?.deliveryAddress?.details?.zipCode ?? "",
|
||||
);
|
||||
// GeoJSON coordinates [longitude, latitude] — required by backend
|
||||
const [delLng, setDelLng] = useState(
|
||||
const [delLng, setDelLng] = useState(
|
||||
editOrder?.deliveryAddress?.location?.coordinates?.[0] != null
|
||||
? String(editOrder.deliveryAddress!.location!.coordinates![0])
|
||||
: "",
|
||||
);
|
||||
const [delLat, setDelLat] = useState(
|
||||
const [delLat, setDelLat] = useState(
|
||||
editOrder?.deliveryAddress?.location?.coordinates?.[1] != null
|
||||
? String(editOrder.deliveryAddress!.location!.coordinates![1])
|
||||
: "",
|
||||
);
|
||||
|
||||
// Pickup address (عنوان الاستلام) — إما ID موجود أو object جديد
|
||||
const [pickupMode, setPickupMode] = useState<"id" | "new">(
|
||||
const [pickupMode, setPickupMode] = useState<"id" | "new">(
|
||||
editOrder?.pickupAddressId ? "id" : "new",
|
||||
);
|
||||
const [pickupAddressId, setPickupAddressId] = useState(editOrder?.pickupAddressId ?? "");
|
||||
const [pkpCity, setPkpCity] = useState(editOrder?.pickupAddress?.details?.city ?? "");
|
||||
const [pkpDistrict, setPkpDistrict] = useState(editOrder?.pickupAddress?.details?.district ?? "");
|
||||
const [pkpStreet, setPkpStreet] = useState(editOrder?.pickupAddress?.details?.street ?? "");
|
||||
const [pkpBuildingNo, setPkpBuildingNo] = useState(editOrder?.pickupAddress?.details?.buildingNo ?? "");
|
||||
const [pkpUnitNo, setPkpUnitNo] = useState(editOrder?.pickupAddress?.details?.unitNo ?? "");
|
||||
const [pkpZipCode, setPkpZipCode] = useState(editOrder?.pickupAddress?.details?.zipCode ?? "");
|
||||
const [pickupAddressId, setPickupAddressId] = useState(
|
||||
editOrder?.pickupAddressId ?? "",
|
||||
);
|
||||
const [pkpCity, setPkpCity] = useState(
|
||||
editOrder?.pickupAddress?.details?.city ?? "",
|
||||
);
|
||||
const [pkpDistrict, setPkpDistrict] = useState(
|
||||
editOrder?.pickupAddress?.details?.district ?? "",
|
||||
);
|
||||
const [pkpStreet, setPkpStreet] = useState(
|
||||
editOrder?.pickupAddress?.details?.street ?? "",
|
||||
);
|
||||
const [pkpBuildingNo, setPkpBuildingNo] = useState(
|
||||
editOrder?.pickupAddress?.details?.buildingNo ?? "",
|
||||
);
|
||||
const [pkpUnitNo, setPkpUnitNo] = useState(
|
||||
editOrder?.pickupAddress?.details?.unitNo ?? "",
|
||||
);
|
||||
const [pkpZipCode, setPkpZipCode] = useState(
|
||||
editOrder?.pickupAddress?.details?.zipCode ?? "",
|
||||
);
|
||||
// GeoJSON coordinates [longitude, latitude] — optional for pickup
|
||||
const [pkpLng, setPkpLng] = useState(
|
||||
const [pkpLng, setPkpLng] = useState(
|
||||
editOrder?.pickupAddress?.location?.coordinates?.[0] != null
|
||||
? String(editOrder.pickupAddress!.location!.coordinates![0])
|
||||
: "",
|
||||
);
|
||||
const [pkpLat, setPkpLat] = useState(
|
||||
const [pkpLat, setPkpLat] = useState(
|
||||
editOrder?.pickupAddress?.location?.coordinates?.[1] != null
|
||||
? String(editOrder.pickupAddress!.location!.coordinates![1])
|
||||
: "",
|
||||
);
|
||||
|
||||
|
||||
const [errors, setErrors] = useState<OrderSchemaErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<OrderSchemaErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [apiError, setApiError] = useState("");
|
||||
const firstRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -185,7 +222,10 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
const clientList: Client[] = clientPayload.data ?? [];
|
||||
|
||||
// Fetch first 100 trips (active ones the order can be assigned to)
|
||||
const tripRes = await tripService.getAll({ page: 1, limit: 100 }, token);
|
||||
const tripRes = await tripService.getAll(
|
||||
{ page: 1, limit: 100 },
|
||||
token,
|
||||
);
|
||||
const tripPayload = (tripRes as any).data ?? tripRes;
|
||||
const tripList: Trip[] = tripPayload.data ?? [];
|
||||
|
||||
@@ -201,19 +241,27 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
// links — but now they also see a message explaining why.
|
||||
console.error("OrderFormModal: failed to load clients/trips", err);
|
||||
if (!cancelled) {
|
||||
setApiError("تعذّر تحميل قوائم العملاء والرحلات. تحقق من اتصالك وحاول إعادة فتح النافذة.");
|
||||
setApiError(
|
||||
"تعذّر تحميل قوائم العملاء والرحلات. تحقق من اتصالك وحاول إعادة فتح النافذة.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setRelLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Keyboard / focus setup ────────────────────────────────────────────────
|
||||
useEffect(() => { firstRef.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
firstRef.current?.focus();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
@@ -221,14 +269,13 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
// ── Dynamic input error style ─────────────────────────────────────────────
|
||||
const inputStyle = (field: keyof OrderSchemaErrors): React.CSSProperties => ({
|
||||
...inputBase,
|
||||
border: errors[field]
|
||||
? "1px solid var(--color-danger)"
|
||||
: inputBase.border,
|
||||
border: errors[field] ? "1px solid var(--color-danger)" : inputBase.border,
|
||||
...(errors[field] ? { background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
const clearFieldError = useCallback(
|
||||
(field: keyof OrderSchemaErrors) => setErrors((p) => ({ ...p, [field]: undefined })),
|
||||
(field: keyof OrderSchemaErrors) =>
|
||||
setErrors((p) => ({ ...p, [field]: undefined })),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -244,11 +291,11 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
recipientName,
|
||||
recipientPhone,
|
||||
quantity: quantity ? Number(quantity) : undefined,
|
||||
weight: weight ? Number(weight) : undefined,
|
||||
weight: weight ? Number(weight) : undefined,
|
||||
subTotal: subTotal ? Number(subTotal) : undefined,
|
||||
vatRate: vatRate ? Number(vatRate) : undefined,
|
||||
paymentMethod: paymentMethod || undefined,
|
||||
paymentStatus: paymentStatus || undefined,
|
||||
vatRate: vatRate ? Number(vatRate) : undefined,
|
||||
paymentMethod: paymentMethod || undefined,
|
||||
paymentStatus: paymentStatus || undefined,
|
||||
type: type || undefined,
|
||||
},
|
||||
{ abortEarly: false },
|
||||
@@ -263,11 +310,26 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
setErrors(bag);
|
||||
return false;
|
||||
}
|
||||
}, [isNew, tripId, clientId, shipmentNumber, recipientName, recipientPhone,
|
||||
quantity, weight, subTotal, vatRate, paymentMethod, paymentStatus, type]);
|
||||
}, [
|
||||
isNew,
|
||||
tripId,
|
||||
clientId,
|
||||
shipmentNumber,
|
||||
recipientName,
|
||||
recipientPhone,
|
||||
quantity,
|
||||
weight,
|
||||
subTotal,
|
||||
vatRate,
|
||||
paymentMethod,
|
||||
paymentStatus,
|
||||
type,
|
||||
]);
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
console.log("her");
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const valid = await runValidation();
|
||||
@@ -289,21 +351,21 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
if (tripId) payload.tripId = tripId;
|
||||
|
||||
// Optional fields — include only when filled
|
||||
if (type) payload.type = type;
|
||||
if (weight) payload.weight = Number(weight);
|
||||
if (subTotal) payload.subTotal = Number(subTotal);
|
||||
if (vatRate) payload.vatRate = Number(vatRate);
|
||||
if (type) payload.type = type;
|
||||
if (weight) payload.weight = Number(weight);
|
||||
if (subTotal) payload.subTotal = Number(subTotal);
|
||||
if (vatRate) payload.vatRate = Number(vatRate);
|
||||
if (paymentMethod) payload.paymentMethod = paymentMethod;
|
||||
if (paymentStatus) payload.paymentStatus = paymentStatus;
|
||||
|
||||
// ── Build delivery address object (only if at least one field filled) ──
|
||||
const delDetails = {
|
||||
...(delCity && { city: delCity }),
|
||||
...(delDistrict && { district: delDistrict }),
|
||||
...(delStreet && { street: delStreet }),
|
||||
...(delCity && { city: delCity }),
|
||||
...(delDistrict && { district: delDistrict }),
|
||||
...(delStreet && { street: delStreet }),
|
||||
...(delBuildingNo && { buildingNo: delBuildingNo }),
|
||||
...(delUnitNo && { unitNo: delUnitNo }),
|
||||
...(delZipCode && { zipCode: delZipCode }),
|
||||
...(delUnitNo && { unitNo: delUnitNo }),
|
||||
...(delZipCode && { zipCode: delZipCode }),
|
||||
};
|
||||
// coordinates are always included when provided (required by backend GeoJSON schema)
|
||||
const delCoordinates =
|
||||
@@ -323,12 +385,12 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
payload.pickupAddressId = pickupAddressId.trim();
|
||||
} else {
|
||||
const pkpDetails = {
|
||||
...(pkpCity && { city: pkpCity }),
|
||||
...(pkpDistrict && { district: pkpDistrict }),
|
||||
...(pkpStreet && { street: pkpStreet }),
|
||||
...(pkpCity && { city: pkpCity }),
|
||||
...(pkpDistrict && { district: pkpDistrict }),
|
||||
...(pkpStreet && { street: pkpStreet }),
|
||||
...(pkpBuildingNo && { buildingNo: pkpBuildingNo }),
|
||||
...(pkpUnitNo && { unitNo: pkpUnitNo }),
|
||||
...(pkpZipCode && { zipCode: pkpZipCode }),
|
||||
...(pkpUnitNo && { unitNo: pkpUnitNo }),
|
||||
...(pkpZipCode && { zipCode: pkpZipCode }),
|
||||
};
|
||||
const pkpCoordinates =
|
||||
pkpLng.trim() && pkpLat.trim()
|
||||
@@ -343,12 +405,14 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setSaving(true);
|
||||
setApiError("");
|
||||
|
||||
try {
|
||||
const ok = await onSubmit(payload as unknown as CreateOrderPayload, isNew);
|
||||
const ok = await onSubmit(
|
||||
payload as unknown as CreateOrderPayload,
|
||||
isNew,
|
||||
);
|
||||
if (ok) {
|
||||
onClose();
|
||||
} else {
|
||||
@@ -356,7 +420,9 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.";
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.";
|
||||
setApiError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -369,57 +435,88 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="order-modal-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
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", overflowY: "auto",
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 640,
|
||||
width: "100%",
|
||||
maxWidth: 640,
|
||||
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", margin: "auto",
|
||||
overflow: "hidden",
|
||||
margin: "auto",
|
||||
}}
|
||||
>
|
||||
{/* ── 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
|
||||
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,
|
||||
}}>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.3em",
|
||||
textTransform: "uppercase",
|
||||
color: "#2563EB",
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{isNew ? "إنشاء طلب" : "تعديل طلب"}
|
||||
</p>
|
||||
<h2
|
||||
id="order-modal-title"
|
||||
style={{
|
||||
fontSize: 17, fontWeight: 700,
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: "4px 0 0", fontFamily: "var(--font-mono)",
|
||||
margin: "4px 0 0",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{isNew ? "طلب جديد" : editOrder?.shipmentNumber}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button" onClick={onClose} aria-label="إغلاق"
|
||||
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",
|
||||
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",
|
||||
}}
|
||||
>
|
||||
×
|
||||
@@ -432,20 +529,32 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
display: "flex", flexDirection: "column", gap: "1rem",
|
||||
maxHeight: "75vh", overflowY: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
maxHeight: "75vh",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
dir="rtl"
|
||||
>
|
||||
{apiError && (
|
||||
<Alert type="error" message={apiError} onClose={() => setApiError("")} />
|
||||
<Alert
|
||||
type="error"
|
||||
message={apiError}
|
||||
onClose={() => setApiError("")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Section: Shipment & Recipient ── */}
|
||||
<p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{/* Shipment number — required */}
|
||||
<label style={labelStyle}>
|
||||
رقم الشحنة *
|
||||
@@ -498,9 +607,19 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
<span style={errorTextStyle}>{errors.clientId}</span>
|
||||
)}
|
||||
{/* Helper link */}
|
||||
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5">
|
||||
<Link
|
||||
href="/dashboard/clients"
|
||||
style={createLinkStyle}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
@@ -576,9 +695,19 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
<span style={errorTextStyle}>{errors.tripId}</span>
|
||||
)}
|
||||
{/* Helper link */}
|
||||
<Link href="/dashboard/trips" style={createLinkStyle} tabIndex={-1}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5">
|
||||
<Link
|
||||
href="/dashboard/trips"
|
||||
style={createLinkStyle}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
@@ -590,7 +719,13 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
{/* ── Section: Shipment details ── */}
|
||||
<p style={sectionHeadingStyle}>تفاصيل الشحنة</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{/* Type — optional */}
|
||||
<label style={labelStyle}>
|
||||
نوع الشحنة
|
||||
@@ -642,7 +777,13 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
{/* ── Section: Payment ── */}
|
||||
<p style={sectionHeadingStyle}>بيانات الدفع</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{/* Subtotal — optional */}
|
||||
<label style={labelStyle}>
|
||||
الإجمالي الفرعي
|
||||
@@ -692,7 +833,9 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
<select
|
||||
style={{ ...inputBase, cursor: "pointer" }}
|
||||
value={paymentMethod}
|
||||
onChange={(e) => setPaymentMethod(e.target.value as PaymentMethod | "")}
|
||||
onChange={(e) =>
|
||||
setPaymentMethod(e.target.value as PaymentMethod | "")
|
||||
}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر الطريقة</option>
|
||||
@@ -726,36 +869,78 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
|
||||
{/* ── Section: Delivery Address ── */}
|
||||
<p style={sectionHeadingStyle}>عنوان التسليم</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<label style={labelStyle}>
|
||||
المدينة
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={delCity} onChange={(e) => setDelCity(e.target.value)} placeholder="الرياض" dir="rtl" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={delCity}
|
||||
onChange={(e) => setDelCity(e.target.value)}
|
||||
placeholder="الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الحي
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={delDistrict} onChange={(e) => setDelDistrict(e.target.value)} placeholder="العليا" dir="rtl" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={delDistrict}
|
||||
onChange={(e) => setDelDistrict(e.target.value)}
|
||||
placeholder="العليا"
|
||||
dir="rtl"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الشارع
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={delStreet} onChange={(e) => setDelStreet(e.target.value)} placeholder="شارع الأمير محمد" dir="rtl" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={delStreet}
|
||||
onChange={(e) => setDelStreet(e.target.value)}
|
||||
placeholder="شارع الأمير محمد"
|
||||
dir="rtl"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
رقم المبنى
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={delBuildingNo} onChange={(e) => setDelBuildingNo(e.target.value)} placeholder="1234" dir="ltr" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={delBuildingNo}
|
||||
onChange={(e) => setDelBuildingNo(e.target.value)}
|
||||
placeholder="1234"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
رقم الوحدة
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={delUnitNo} onChange={(e) => setDelUnitNo(e.target.value)} placeholder="56" dir="ltr" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={delUnitNo}
|
||||
onChange={(e) => setDelUnitNo(e.target.value)}
|
||||
placeholder="56"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الرمز البريدي
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={delZipCode} onChange={(e) => setDelZipCode(e.target.value)} placeholder="12345" dir="ltr" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={delZipCode}
|
||||
onChange={(e) => setDelZipCode(e.target.value)}
|
||||
placeholder="12345"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
{/* GeoJSON coordinates — required by backend */}
|
||||
<label style={labelStyle}>
|
||||
@@ -788,20 +973,31 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
<p style={sectionHeadingStyle}>عنوان الاستلام</p>
|
||||
|
||||
{/* Toggle: existing ID or new address */}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.25rem" }}>
|
||||
<div
|
||||
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.25rem" }}
|
||||
>
|
||||
{(["id", "new"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setPickupMode(m)}
|
||||
style={{
|
||||
height: 32, padding: "0 0.875rem",
|
||||
height: 32,
|
||||
padding: "0 0.875rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${pickupMode === m ? "var(--color-brand-600)" : "var(--color-border)"}`,
|
||||
background: pickupMode === m ? "var(--color-brand-50, #EFF6FF)" : "var(--color-surface)",
|
||||
fontSize: 12, fontWeight: 600,
|
||||
color: pickupMode === m ? "var(--color-brand-600)" : "var(--color-text-secondary)",
|
||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||
background:
|
||||
pickupMode === m
|
||||
? "var(--color-brand-50, #EFF6FF)"
|
||||
: "var(--color-surface)",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color:
|
||||
pickupMode === m
|
||||
? "var(--color-brand-600)"
|
||||
: "var(--color-text-secondary)",
|
||||
cursor: "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{m === "id" ? "عنوان موجود (ID)" : "عنوان جديد"}
|
||||
@@ -822,36 +1018,78 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<label style={labelStyle}>
|
||||
المدينة
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={pkpCity} onChange={(e) => setPkpCity(e.target.value)} placeholder="جدة" dir="rtl" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={pkpCity}
|
||||
onChange={(e) => setPkpCity(e.target.value)}
|
||||
placeholder="جدة"
|
||||
dir="rtl"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الحي
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={pkpDistrict} onChange={(e) => setPkpDistrict(e.target.value)} placeholder="الروضة" dir="rtl" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={pkpDistrict}
|
||||
onChange={(e) => setPkpDistrict(e.target.value)}
|
||||
placeholder="الروضة"
|
||||
dir="rtl"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الشارع
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={pkpStreet} onChange={(e) => setPkpStreet(e.target.value)} placeholder="شارع التحلية" dir="rtl" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={pkpStreet}
|
||||
onChange={(e) => setPkpStreet(e.target.value)}
|
||||
placeholder="شارع التحلية"
|
||||
dir="rtl"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
رقم المبنى
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={pkpBuildingNo} onChange={(e) => setPkpBuildingNo(e.target.value)} placeholder="4321" dir="ltr" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={pkpBuildingNo}
|
||||
onChange={(e) => setPkpBuildingNo(e.target.value)}
|
||||
placeholder="4321"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
رقم الوحدة
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={pkpUnitNo} onChange={(e) => setPkpUnitNo(e.target.value)} placeholder="12" dir="ltr" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={pkpUnitNo}
|
||||
onChange={(e) => setPkpUnitNo(e.target.value)}
|
||||
placeholder="12"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الرمز البريدي
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={pkpZipCode} onChange={(e) => setPkpZipCode(e.target.value)} placeholder="23456" dir="ltr" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={pkpZipCode}
|
||||
onChange={(e) => setPkpZipCode(e.target.value)}
|
||||
placeholder="23456"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
{/* GeoJSON coordinates — optional for pickup */}
|
||||
<label style={labelStyle}>
|
||||
@@ -884,20 +1122,27 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
)}
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div style={{
|
||||
display: "flex", gap: "0.5rem",
|
||||
justifyContent: "flex-end", paddingTop: "0.5rem",
|
||||
}}>
|
||||
<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",
|
||||
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)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
@@ -907,14 +1152,22 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
onClick={() => console.log("BUTTON CLICKED DIRECTLY")}
|
||||
style={{
|
||||
height: 40, padding: "0 1.5rem",
|
||||
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",
|
||||
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,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
@@ -926,4 +1179,4 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user