update order pages and create client pages also client adresses pages

This commit is contained in:
m7amedez5511
2026-06-25 21:34:31 +03:00
parent a18ed59ac1
commit 0847bd23ee
18 changed files with 2619 additions and 984 deletions

View File

@@ -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>
);
}

View File

@@ -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 = "رقم هاتف غير صالح (1015 رقم)";
}
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>

View File

@@ -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>
)}

View File

@@ -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";

View File

@@ -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";

View 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>
);
}

View File

@@ -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>
);
}
}