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,23 +1,36 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Alert } from "@/src/Components/UI";
import { Toast } from "@/src/Components/Client/Toast"; // ── UI components (canonical barrel — no old Client/Toast) ─────────────────
import { Alert, Toast } from "@/src/Components/UI";
// ── Hooks & services ───────────────────────────────────────────────────────
import { useClientAddresses } from "@/src/hooks/useClientAddresses"; import { useClientAddresses } from "@/src/hooks/useClientAddresses";
import { clientService } from "@/src/services/client.service"; import { clientService } from "@/src/services/client.service";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
// ── Types ──────────────────────────────────────────────────────────────────
import type { import type {
Client, Client,
ClientAddress, ClientAddress,
ClientAddressFormData, ClientAddressFormData,
ClientFormData,
} from "@/src/types/client"; } from "@/src/types/client";
import { AddressFormModal } from "@/src/Components/Client/Addressformmodal";
import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Client-specific modals ─────────────────────────────────────────────────
import { AddressFormModal,DeleteConfirmModal ,ClientFormModal } from "@/src/Components/Client";
import { CreateAddressFormValues, UpdateAddressFormValues } from "@/src/validations/client_address.validator";
/** Returns a context-appropriate emoji for each common address label */
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Emoji icon for known address label types */
function labelIcon(label: string): string { function labelIcon(label: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
"فوترة": "💳", "فوترة": "💳",
@@ -34,16 +47,29 @@ function labelIcon(label: string): string {
return map[label.toLowerCase()] ?? "📍"; return map[label.toLowerCase()] ?? "📍";
} }
// ── Address card sub-component ───────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Mirrors the AddressCard in the old page — kept co-located because it's only used here. // AddressCard sub-component
// ─────────────────────────────────────────────────────────────────────────────
interface AddressCardProps { interface AddressCardProps {
address: ClientAddress; address: ClientAddress;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onMakePrimary: () => void; onMakePrimary: () => void;
/** Whether a "set primary" action is pending for this specific card */
settingPrimary?: boolean;
} }
function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardProps) { /**
* AddressCard — Renders a single address in the grid.
*/
function AddressCard({
address,
onEdit,
onDelete,
onMakePrimary,
settingPrimary = false,
}: AddressCardProps) {
return ( return (
<div <div
style={{ style={{
@@ -60,18 +86,34 @@ function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardPr
boxShadow: address.isPrimary boxShadow: address.isPrimary
? "0 0 0 2px #BFDBFE, var(--shadow-card)" ? "0 0 0 2px #BFDBFE, var(--shadow-card)"
: "var(--shadow-card)", : "var(--shadow-card)",
transition: "box-shadow 200ms",
}} }}
> >
{/* Label row */} {/* Label row */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}> <div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 20 }} aria-hidden="true"> <span style={{ fontSize: 20 }} aria-hidden="true">
{labelIcon(address.label)} {labelIcon(address.label)}
</span> </span>
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--color-text-primary)" }}> <span
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--color-text-primary)",
}}
>
{address.label} {address.label}
</span> </span>
</div> </div>
{/* Primary badge */}
{address.isPrimary && ( {address.isPrimary && (
<span <span
style={{ style={{
@@ -82,9 +124,10 @@ function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardPr
border: "1px solid #BFDBFE", border: "1px solid #BFDBFE",
borderRadius: "var(--radius-full)", borderRadius: "var(--radius-full)",
padding: "0.15rem 0.5rem", padding: "0.15rem 0.5rem",
flexShrink: 0,
}} }}
> >
رئيسي رئيسي
</span> </span>
)} )}
</div> </div>
@@ -101,7 +144,7 @@ function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardPr
> >
<p style={{ margin: 0 }}>{address.street}</p> <p style={{ margin: 0 }}>{address.street}</p>
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
{address.city}، {address.state} {address.postalCode} {address.city}،{" "}{address.state} {address.postalCode}
</p> </p>
<p style={{ margin: 0 }}>{address.country}</p> <p style={{ margin: 0 }}>{address.country}</p>
</address> </address>
@@ -117,18 +160,28 @@ function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardPr
borderTop: "1px solid var(--color-border)", borderTop: "1px solid var(--color-border)",
}} }}
> >
<ActionBtn onClick={onEdit} color="#1D4ED8" bg="#EFF6FF" border="#BFDBFE"> <ActionBtn
onClick={onEdit}
color="#1D4ED8"
bg="#EFF6FF"
border="#BFDBFE"
>
تعديل تعديل
</ActionBtn> </ActionBtn>
{/*
* Set-primary button — only for non-primary addresses.
* Shows a loading indicator while the API call is in-flight.
*/}
{!address.isPrimary && ( {!address.isPrimary && (
<ActionBtn <ActionBtn
onClick={onMakePrimary} onClick={onMakePrimary}
color="#065F46" color="#065F46"
bg="#DCFCE7" bg="#DCFCE7"
border="#BBF7D0" border="#BBF7D0"
disabled={settingPrimary}
> >
تعيين كرئيسي {settingPrimary ? "…" : "تعيين كرئيسي"}
</ActionBtn> </ActionBtn>
)} )}
@@ -152,6 +205,7 @@ function ActionBtn({
bg, bg,
border, border,
style, style,
disabled,
children, children,
}: { }: {
onClick: () => void; onClick: () => void;
@@ -159,12 +213,14 @@ function ActionBtn({
bg: string; bg: string;
border: string; border: string;
style?: React.CSSProperties; style?: React.CSSProperties;
disabled?: boolean;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={disabled}
style={{ style={{
height: 30, height: 30,
padding: "0 0.75rem", padding: "0 0.75rem",
@@ -174,8 +230,10 @@ function ActionBtn({
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
color, color,
cursor: "pointer", cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
transition: "opacity 150ms",
...style, ...style,
}} }}
> >
@@ -184,25 +242,303 @@ function ActionBtn({
); );
} }
// ── Page ─────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// AddressMiniList — used inside the client edit modal
// ─────────────────────────────────────────────────────────────────────────────
/**
* AddressMiniList — A compact read-only list of addresses shown inside
* the ClientFormModal when editing an existing client.
*
* Purpose:
* Allows the user to see all addresses and designate one as primary
* without navigating away from the edit modal.
*
* Props:
* addresses — current list from useClientAddresses
* onMakePrimary — handler that calls clientAddressService.setPrimary
* settingId — id of the address currently being promoted (for loading state)
*/
function AddressMiniList({
addresses,
onMakePrimary,
settingId,
}: {
addresses: ClientAddress[];
onMakePrimary: (id: string) => void;
settingId: string | null;
}) {
if (addresses.length === 0) {
return (
<div
style={{
borderRadius: "var(--radius-lg)",
border: "1px dashed var(--color-border)",
padding: "1rem",
textAlign: "center",
fontSize: 12,
color: "var(--color-text-muted)",
}}
>
لا توجد عناوين مضافة بعد.
</div>
);
}
return (
<ul
dir="rtl"
style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}
>
{addresses.map((addr) => (
<li
key={addr.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
padding: "0.625rem 0.875rem",
borderRadius: "var(--radius-md)",
border: addr.isPrimary ? "1px solid #93C5FD" : "1px solid var(--color-border)",
background: addr.isPrimary ? "#EFF6FF" : "var(--color-surface-muted)",
fontSize: 12,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span aria-hidden="true">{labelIcon(addr.label)}</span>
<div>
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>
{addr.label}
</span>
<span style={{ color: "var(--color-text-muted)", marginRight: 8 }}>
{addr.city}، {addr.state}
</span>
</div>
</div>
{addr.isPrimary ? (
<span
style={{
fontSize: 10,
fontWeight: 700,
color: "#1D4ED8",
background: "#DBEAFE",
border: "1px solid #BFDBFE",
borderRadius: "var(--radius-full)",
padding: "0.1rem 0.45rem",
flexShrink: 0,
}}
>
رئيسي
</span>
) : (
<button
type="button"
onClick={() => onMakePrimary(addr.id)}
disabled={settingId === addr.id}
style={{
height: 24,
padding: "0 0.5rem",
borderRadius: "var(--radius-md)",
border: "1px solid #BBF7D0",
background: "#DCFCE7",
fontSize: 11,
fontWeight: 600,
color: "#065F46",
cursor: settingId === addr.id ? "not-allowed" : "pointer",
opacity: settingId === addr.id ? 0.6 : 1,
fontFamily: "var(--font-sans)",
flexShrink: 0,
}}
>
{settingId === addr.id ? "…" : "تعيين كرئيسي"}
</button>
)}
</li>
))}
</ul>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// ClientEditModalWithAddresses — wraps ClientFormModal + AddressMiniList
// ─────────────────────────────────────────────────────────────────────────────
/**
* ClientEditModalWithAddresses
*/
interface ClientEditModalWithAddressesProps {
client: Client;
addresses: ClientAddress[];
onClose: () => void;
onSubmit: (data: ClientFormData, isNew: boolean) => Promise<boolean>;
onMakePrimary: (id: string) => Promise<boolean>;
}
function ClientEditModalWithAddresses({
client,
addresses,
onClose,
onSubmit,
onMakePrimary,
}: ClientEditModalWithAddressesProps) {
const [settingId, setSettingId] = useState<string | null>(null);
const handleSetPrimary = async (id: string) => {
setSettingId(id);
await onMakePrimary(id);
setSettingId(null);
};
return (
// We reuse the backdrop and center the compound modal manually
<div
role="dialog"
aria-modal="true"
aria-label="تعديل بيانات العميل"
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: "flex-start",
justifyContent: "center",
padding: "2rem 1rem",
overflowY: "auto",
gap: "1rem",
}}
>
{/* Left panel: client form */}
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: 520,
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
{/* Render ClientFormModal in a "portal-less" way by embedding its JSX inline */}
<div
style={{
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",
}}
>
{/* We mount ClientFormModal as a standalone dialog — it handles its own
form logic. We intercept onClose to close the whole compound modal. */}
<ClientFormModal
editClient={client}
onClose={onClose}
onSubmit={onSubmit}
/>
</div>
{/*
* Address section — appended below the form card.
* Satisfies: "create a section that displays all available addresses."
*/}
<div
style={{
background: "var(--color-surface)",
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
boxShadow: "var(--shadow-card)",
overflow: "hidden",
}}
>
{/* Section header */}
<div
style={{
padding: "0.875rem 1.25rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<p
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "#2563EB",
margin: 0,
}}
>
عناوين العميل
</p>
<p
style={{
margin: "2px 0 0",
fontSize: 12,
color: "var(--color-text-muted)",
}}
>
{addresses.length} عنوان اضغط "تعيين كرئيسي" لتغيير العنوان الرئيسي
</p>
</div>
</div>
{/* Address mini-list */}
<div style={{ padding: "1rem" }}>
<AddressMiniList
addresses={addresses}
onMakePrimary={handleSetPrimary}
settingId={settingId}
/>
</div>
</div>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Page component
// ─────────────────────────────────────────────────────────────────────────────
export default function ClientAddressesPage() { export default function ClientAddressesPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const clientId = params?.clientId as string;
console.log('clientId:', params.clientId); // شوف إيه اللي بيجي // FIX: "id is undefined" 404 bug.
// params.clientId only works if your route folder is named [clientId].
// If your folder is actually [id], params.clientId is always undefined,
// which becomes the string "undefined" in API URLs like /client/undefined/addresses.
// CHECK YOUR FOLDER NAME and keep only the line that matches it:
const clientId = (params?.clientId ?? params?.id) as string | undefined;
// FIX: warn loudly in dev instead of silently calling the API with no id
useEffect(() => {
if (!clientId) {
console.warn("ClientAddressesPage: clientId is missing from route params. Check your folder name (must match the key used in useParams()).");
}
}, [clientId]);
// ── Parent client meta ─────────────────────────────────────────────────── // ── Parent client meta ───────────────────────────────────────────────────
const [client, setClient] = useState<Client | null>(null); const [client, setClient] = useState<Client | null>(null);
const [clientLoading, setClientLoading] = useState(true); const [clientLoading, setClientLoading] = useState(true);
// Fetch the parent client so we can show their name / details in the header
useEffect(() => { useEffect(() => {
if (!clientId) return; if (!clientId) return;
setClientLoading(true); setClientLoading(true);
clientService clientService
.getById(clientId, getStoredToken()) .getById(clientId, getStoredToken())
.then((res) => setClient(res.data)) .then((res) => setClient(res.data))
.catch(() => router.replace("/clients")) .catch(() => router.replace("/dashboard/clients"))
.finally(() => setClientLoading(false)); .finally(() => setClientLoading(false));
}, [clientId, router]); }, [clientId, router]);
@@ -211,29 +547,55 @@ export default function ClientAddressesPage() {
addresses, addresses,
loading: addrLoading, loading: addrLoading,
error, error,
notification, notification, // { type, message } — matches ToastNotification
clearError, clearError,
createAddress, createAddress,
updateAddress, updateAddress,
deleteAddress, deleteAddress,
makePrimary, makePrimary,
reload,
} = useClientAddresses(clientId ?? ""); } = useClientAddresses(clientId ?? "");
// NOTE: hook itself guards "if (!clientId) return" before calling the API,
// so passing "" here is safe — it just won't fetch until clientId is real.
// ── Modal state (mirrors UsersPage pattern) ────────────────────────────── // ── Modal state ──────────────────────────────────────────────────────────
// false = closed | null = create mode | ClientAddress = edit mode // Address form: false = closed | null = create | ClientAddress = edit
const [formTarget, setFormTarget] = useState<ClientAddress | null | false>(false); const [addrFormTarget, setAddrFormTarget] = useState<ClientAddress | null | false>(false);
const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null); const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
// ── Handlers ───────────────────────────────────────────────────────────── // Client edit modal (with address section)
const handleFormSubmit = async ( const [editingClient, setEditingClient] = useState(false);
data: ClientAddressFormData,
isNew: boolean // Per-card primary-setting loading state (card id)
const [settingPrimaryId, setSettingPrimaryId] = useState<string | null>(null);
// ── Address form handler ─────────────────────────────────────────────────
// FIX: "addrFormTarget!.id" used "!" which only blocks null/undefined,
// NOT "false" — but addrFormTarget's type is "ClientAddress | null | false".
// If addrFormTarget was ever false here, ".id" would be undefined at runtime,
// causing the "id is undefined" 404 bug from the API call.
// We now check addrFormTarget directly instead of trusting the isNew param,
// so create vs update can never disagree with each other.
const handleAddressSubmit = async (
data: CreateAddressFormValues | UpdateAddressFormValues
): Promise<boolean> => { ): Promise<boolean> => {
if (isNew) return createAddress(data); if (addrFormTarget === null) {
return updateAddress((formTarget as ClientAddress).id, data); // create mode
return createAddress(data as CreateAddressFormValues);
}
if (addrFormTarget === false) {
// modal isn't even open — this should never happen, but guard anyway
console.error("handleAddressSubmit called while addrFormTarget is false (modal closed)");
return false;
}
// update mode — addrFormTarget is a real ClientAddress here, .id is safe
return updateAddress(addrFormTarget.id, data as UpdateAddressFormValues);
}; };
// ── Delete handler ───────────────────────────────────────────────────────
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
if (!deleteTarget || deleting) return; if (!deleteTarget || deleting) return;
setDeleting(true); setDeleting(true);
@@ -242,7 +604,35 @@ export default function ClientAddressesPage() {
if (ok) setDeleteTarget(null); if (ok) setDeleteTarget(null);
}; };
// ── Guard: wait for router + client ───────────────────────────────────── // ── Set-primary handler ──────────────────────────────────────────────────
/**
* makePrimaryForCard — wraps makePrimary from the hook with per-card
* loading state so each card can show its own spinner independently.
*/
const makePrimaryForCard = async (id: string): Promise<boolean> => {
setSettingPrimaryId(id);
const ok = await makePrimary(id);
setSettingPrimaryId(null);
return ok;
};
// ── Client edit submit ───────────────────────────────────────────────────
const handleClientEditSubmit = async (
data: ClientFormData,
_isNew: boolean,
): Promise<boolean> => {
if (!client) return false;
try {
const token = getStoredToken();
const res = await clientService.update(client.id, data, token);
setClient(res.data); // optimistically update local state
return true;
} catch {
return false;
}
};
// ── Guard: wait for client ───────────────────────────────────────────────
if (!clientId || clientLoading) { if (!clientId || clientLoading) {
return ( return (
<div <div
@@ -270,22 +660,29 @@ export default function ClientAddressesPage() {
// ── Render ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
return ( return (
<> <>
{/* Global success / error toast */} {/*
* Global toast — sourced from UI barrel (not the old Client/Toast).
* The notification object from useClientAddresses has the same shape
* as ToastNotification: { type: "success" | "error"; message: string }.
*/}
<Toast notification={notification} /> <Toast notification={notification} />
{/* Create / Edit address modal */} {/* ── Address create / edit modal ── */}
{formTarget !== false && ( {addrFormTarget !== false && (
<AddressFormModal <AddressFormModal
editAddress={formTarget} editAddress={addrFormTarget}
onClose={() => setFormTarget(false)} onClose={() => setAddrFormTarget(false)}
onSubmit={handleFormSubmit} onSubmit={handleAddressSubmit}
/> />
)} )}
{/* Delete confirmation — reuses the same DeleteConfirmModal shape */} {/*
* ── Address delete confirmation ──
* We cast the address to the Client shape that DeleteConfirmModal expects
* (it only uses id + name fields from Client).
*/}
{deleteTarget && ( {deleteTarget && (
<DeleteConfirmModal <DeleteConfirmModal
// Cast address as Client for the modal (same shape: id + name)
client={ client={
{ {
...deleteTarget, ...deleteTarget,
@@ -302,6 +699,21 @@ export default function ClientAddressesPage() {
/> />
)} )}
{/*
* ── Client edit modal with embedded address section ──
* TEST: Click "تعديل بيانات العميل" in the header.
* → Modal shows client form + address list with "تعيين كرئيسي" buttons.
*/}
{editingClient && client && (
<ClientEditModalWithAddresses
client={client}
addresses={addresses}
onClose={() => setEditingClient(false)}
onSubmit={handleClientEditSubmit}
onMakePrimary={makePrimaryForCard}
/>
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}> <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Page header ── */} {/* ── Page header ── */}
@@ -317,7 +729,7 @@ export default function ClientAddressesPage() {
{/* Back link */} {/* Back link */}
<button <button
type="button" type="button"
onClick={() => router.push("/clients")} onClick={() => router.push("/dashboard/clients")}
style={{ style={{
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
@@ -332,7 +744,14 @@ export default function ClientAddressesPage() {
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 18l-6-6 6-6" /> <path d="M15 18l-6-6 6-6" />
</svg> </svg>
جميع العملاء جميع العملاء
@@ -349,6 +768,7 @@ export default function ClientAddressesPage() {
> >
إدارة العناوين إدارة العناوين
</p> </p>
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> <div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<h1 <h1
@@ -376,10 +796,48 @@ export default function ClientAddressesPage() {
</p> </p>
</div> </div>
{/* Header action buttons */}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{/* Edit client (opens compound modal with address section) */}
{client && (
<button
type="button"
onClick={() => setEditingClient(true)}
style={{
height: 40,
padding: "0 1rem",
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 7,
fontFamily: "var(--font-sans)",
}}
>
<svg
width="13"
height="13"
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>
)}
{/* Add address button */} {/* Add address button */}
<button <button
type="button" type="button"
onClick={() => setFormTarget(null)} onClick={() => setAddrFormTarget(null)}
style={{ style={{
height: 40, height: 40,
padding: "0 1.125rem", padding: "0 1.125rem",
@@ -398,13 +856,21 @@ export default function ClientAddressesPage() {
whiteSpace: "nowrap", whiteSpace: "nowrap",
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<line x1="12" y1="5" x2="12" y2="19" /> <line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
إضافة عنوان إضافة عنوان
</button> </button>
</div> </div>
</div>
{/* Client info strip */} {/* Client info strip */}
{client && ( {client && (
@@ -421,7 +887,12 @@ export default function ClientAddressesPage() {
borderTop: "1px solid var(--color-border)", borderTop: "1px solid var(--color-border)",
}} }}
> >
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}> <span
style={{
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
{client.name} {client.name}
</span> </span>
<a <a
@@ -448,11 +919,18 @@ export default function ClientAddressesPage() {
)} )}
</header> </header>
{/* General load error */} {/*
{error && <Alert type="error" message={error} onClose={clearError} />} * Error alert — shown when the address load fails.
* Uses the UI/Alert component with an onClose dismiss handler.
* TEST: Simulate a network error → alert banner appears above the grid.
*/}
{error && (
<Alert type="error" message={error} onClose={clearError} />
)}
{/* Address grid */} {/* ── Address grid ── */}
{addrLoading ? ( {addrLoading ? (
/* Loading skeleton-ish state */
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -460,12 +938,25 @@ export default function ClientAddressesPage() {
justifyContent: "center", justifyContent: "center",
padding: "4rem 0", padding: "4rem 0",
color: "var(--color-text-muted)", color: "var(--color-text-muted)",
gap: 12,
fontSize: 13, fontSize: 13,
}} }}
> >
جارٍ التحميل <div
style={{
width: 20,
height: 20,
borderRadius: "50%",
border: "2px solid var(--color-border)",
borderTopColor: "var(--color-brand-600)",
animation: "spin 0.7s linear infinite",
flexShrink: 0,
}}
/>
جارٍ تحميل العناوين
</div> </div>
) : addresses.length === 0 ? ( ) : addresses.length === 0 ? (
/* Empty state */
<div <div
style={{ style={{
borderRadius: "var(--radius-xl)", borderRadius: "var(--radius-xl)",
@@ -498,7 +989,7 @@ export default function ClientAddressesPage() {
</p> </p>
<button <button
type="button" type="button"
onClick={() => setFormTarget(null)} onClick={() => setAddrFormTarget(null)}
style={{ style={{
marginTop: 16, marginTop: 16,
fontSize: 13, fontSize: 13,
@@ -514,6 +1005,14 @@ export default function ClientAddressesPage() {
</button> </button>
</div> </div>
) : ( ) : (
/*
* Address cards grid.
* TEST:
* - Click "تعديل" → AddressFormModal opens pre-filled.
* - Click "تعيين كرئيسي" → button shows "…" loading, then card re-renders
* with "★ رئيسي" badge and blue ring; previously primary card loses the badge.
* - Click "حذف" → DeleteConfirmModal opens → confirm → card removed, toast shown.
*/
<div <div
style={{ style={{
display: "grid", display: "grid",
@@ -525,9 +1024,10 @@ export default function ClientAddressesPage() {
<AddressCard <AddressCard
key={addr.id} key={addr.id}
address={addr} address={addr}
onEdit={() => setFormTarget(addr)} onEdit={() => setAddrFormTarget(addr)}
onDelete={() => setDeleteTarget(addr)} onDelete={() => setDeleteTarget(addr)}
onMakePrimary={() => makePrimary(addr.id)} onMakePrimary={() => makePrimaryForCard(addr.id)}
settingPrimary={settingPrimaryId === addr.id}
/> />
))} ))}
</div> </div>

View File

@@ -1,9 +1,32 @@
"use client"; "use client";
/**
* app/dashboard/clients/page.tsx — Enhanced
*
* Changes from original:
* 1. Replaced `import { Toast } from "@/src/Components/Client/Toast"` with
* `import { Toast } from "@/src/Components/UI"` (the canonical, feature-rich version
* that supports an optional dismiss button and proper exit animation).
* 2. The notification type is now `ToastNotification` from the UI barrel, matching
* the Toast's expected prop type.
* 3. All other logic (CRUD, routing, modal management) is unchanged.
*
* HOW TO TEST:
* - Add a client → green toast "تم إضافة العميل بنجاح."
* - Edit a client → green toast "تم تحديث بيانات العميل."
* - Delete a client → green toast "تم حذف العميل بنجاح."
* - Click a table row → detail panel expands inline.
* - Click "العناوين" inside the panel → navigates to /dashboard/clients/[id]/addresses.
*/
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Alert } from "@/src/Components/UI";
import { Toast } from "@/src/Components/Client/Toast"; // ── UI components ──────────────────────────────────────────────────────────
// NOTE: Toast is now imported from the canonical UI barrel, NOT from Client/Toast.
import { Alert, Toast } from "@/src/Components/UI";
// ── Client-specific components ─────────────────────────────────────────────
import { useClients } from "@/src/hooks/useClients"; import { useClients } from "@/src/hooks/useClients";
import type { Client, ClientFormData } from "@/src/types/client"; import type { Client, ClientFormData } from "@/src/types/client";
import { ClientFormModal } from "@/src/Components/Client/Clientformmodal"; import { ClientFormModal } from "@/src/Components/Client/Clientformmodal";
@@ -31,7 +54,7 @@ export default function ClientsPage() {
// ── Create / Update handler ────────────────────────────────────────────── // ── Create / Update handler ──────────────────────────────────────────────
const handleFormSubmit = async ( const handleFormSubmit = async (
data: ClientFormData, data: ClientFormData,
isNew: boolean isNew: boolean,
): Promise<boolean> => { ): Promise<boolean> => {
if (isNew) return createClient(data); if (isNew) return createClient(data);
return updateClient((formTarget as Client).id, data); return updateClient((formTarget as Client).id, data);
@@ -47,6 +70,8 @@ export default function ClientsPage() {
}; };
// ── Navigate to address management ────────────────────────────────────── // ── Navigate to address management ──────────────────────────────────────
// This is called both by the AddressBadge click and the "العناوين" button
// inside the ClientDetailPanel.
const handleManageAddresses = (client: Client) => { const handleManageAddresses = (client: Client) => {
router.push(`/dashboard/clients/${client.id}/addresses`); router.push(`/dashboard/clients/${client.id}/addresses`);
}; };
@@ -54,7 +79,11 @@ export default function ClientsPage() {
// ── Render ─────────────────────────────────────────────────────────────── // ── Render ───────────────────────────────────────────────────────────────
return ( return (
<> <>
{/* Global success / error toast */} {/*
* Global toast notification.
* Using the new UI/Toast which accepts ToastNotification | null.
* The hook's `notification` shape { type, message } matches ToastNotification exactly.
*/}
<Toast notification={notification} /> <Toast notification={notification} />
{/* Create / Edit modal */} {/* Create / Edit modal */}

36
package-lock.json generated
View File

@@ -8,10 +8,12 @@
"name": "logistic_client", "name": "logistic_client",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0",
"@tabler/icons-webfont": "^3.44.0", "@tabler/icons-webfont": "^3.44.0",
"next": "16.2.7", "next": "16.2.7",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.80.0",
"yup": "^1.7.1" "yup": "^1.7.1"
}, },
"devDependencies": { "devDependencies": {
@@ -457,6 +459,18 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@hookform/resolvers": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
"integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1330,6 +1344,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -7097,6 +7117,22 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-hook-form": {
"version": "7.80.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.80.0.tgz",
"integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@@ -9,10 +9,12 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0",
"@tabler/icons-webfont": "^3.44.0", "@tabler/icons-webfont": "^3.44.0",
"next": "16.2.7", "next": "16.2.7",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.80.0",
"yup": "^1.7.1" "yup": "^1.7.1"
}, },
"devDependencies": { "devDependencies": {

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"; "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 { 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 = { const S = {
input: { input: {
width: "100%", width: "100%",
@@ -33,83 +42,58 @@ const S = {
} as React.CSSProperties, } as React.CSSProperties,
}; };
// ── Validation ───────────────────────────────────────────────────────────── const withError = (hasError: boolean): React.CSSProperties => ({
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; ...S.input,
const PHONE_RE = /^\+?[0-9]{10,15}$/; ...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
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;
}
// ── Props ────────────────────────────────────────────────────────────────── // ── Props ──────────────────────────────────────────────────────────────────
interface ClientFormModalProps { interface ClientFormModalProps {
editClient: Client | null; // null = create mode, Client = edit mode editClient: Client | null;
onClose: () => void; onClose: () => void;
onSubmit: (data: ClientFormData, isNew: boolean) => Promise<boolean>; onSubmit: (
data: CreateClientFormValues | UpdateClientFormValues,
isNew: boolean
) => Promise<boolean>;
} }
// ── Component ────────────────────────────────────────────────────────────── // ── Component ──────────────────────────────────────────────────────────────
export function ClientFormModal({ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormModalProps) {
editClient,
onClose,
onSubmit,
}: ClientFormModalProps) {
const isNew = editClient === null; const isNew = editClient === null;
const [form, setForm] = useState<ClientFormData>({ // 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 ?? "", name: editClient?.name ?? "",
email: editClient?.email ?? "", email: editClient?.email ?? "",
phone: editClient?.phone ?? "", phone: editClient?.phone ?? "",
taxId: editClient?.taxId ?? "", clientType: editClient?.clientType ?? undefined,
notes: editClient?.notes ?? "", },
}); });
const [errors, setErrors] = useState<ClientFormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const firstInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { firstInputRef.current?.focus(); }, []);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [onClose]); }, [onClose]);
const set = (field: keyof ClientFormData) => const submitHandler = async (data: CreateClientFormValues | UpdateClientFormValues) => {
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const ok = await onSubmit(data, isNew);
setForm((p) => ({ ...p, [field]: e.target.value })); if (ok) {
if (errors[field]) setErrors((p) => ({ ...p, [field]: undefined })); onClose();
} else {
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
}
}; };
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errs = validate(form);
if (Object.keys(errs).length) { setErrors(errs); return; }
setSaving(true);
setApiError("");
const ok = await onSubmit(form, isNew);
setSaving(false);
if (ok) onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
};
const inputStyle = (field: keyof ClientFormErrors): React.CSSProperties => ({
...S.input,
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
return ( return (
<div <div
role="dialog" role="dialog"
@@ -200,7 +184,7 @@ export function ClientFormModal({
{/* Body */} {/* Body */}
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit(submitHandler)}
noValidate noValidate
style={{ style={{
padding: "1.5rem", padding: "1.5rem",
@@ -209,26 +193,24 @@ export function ClientFormModal({
gap: "1rem", gap: "1rem",
}} }}
> >
{apiError && ( {errors.name?.type === "manual" && (
<Alert type="error" message={apiError} onClose={() => setApiError("")} /> <Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
)} )}
{/* Name */}
<label style={S.label}> <label style={S.label}>
اسم العميل * اسم العميل {isNew && "*"}
<input <input
ref={firstInputRef} {...register("name")}
style={inputStyle("name")} style={withError(!!errors.name)}
value={form.name}
onChange={set("name")}
placeholder="شركة لوجي فلو للتوصيل" placeholder="شركة لوجي فلو للتوصيل"
autoComplete="organization" autoComplete="organization"
dir="rtl" 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> </label>
{/* Email + Phone */}
<div <div
style={{ style={{
display: "grid", display: "grid",
@@ -237,63 +219,77 @@ export function ClientFormModal({
}} }}
> >
<label style={S.label}> <label style={S.label}>
البريد الإلكتروني * البريد الإلكتروني
<input <input
style={inputStyle("email")} {...register("email")}
style={withError(!!errors.email)}
type="email" type="email"
value={form.email}
onChange={set("email")}
placeholder="info@company.sa" placeholder="info@company.sa"
autoComplete="email" autoComplete="email"
dir="ltr" dir="ltr"
/> />
{errors.email && <span style={S.errorText}>{errors.email}</span>} {errors.email && (
<span style={S.errorText}>{errors.email.message}</span>
)}
</label> </label>
<label style={S.label}> <label style={S.label}>
رقم الهاتف * رقم الهاتف {isNew && "*"}
<input <input
style={inputStyle("phone")} {...register("phone")}
style={withError(!!errors.phone)}
type="tel" type="tel"
value={form.phone} placeholder="05xxxxxxxx"
onChange={set("phone")}
placeholder="+966 5x xxx xxxx"
autoComplete="tel" autoComplete="tel"
dir="ltr" dir="ltr"
/> />
{errors.phone && <span style={S.errorText}>{errors.phone}</span>} {errors.phone && (
<span style={S.errorText}>{errors.phone.message}</span>
)}
</label> </label>
</div> </div>
{/* Tax ID */}
<label style={S.label}> <label style={S.label}>
الرقم الضريبي (اختياري) نوع العميل
<input <select
style={S.input} {...register("clientType")}
value={form.taxId} style={{ ...withError(!!errors.clientType), cursor: "pointer" }}
onChange={set("taxId")}
placeholder="300xxxxxxxxx"
dir="ltr"
/>
</label>
{/* Notes */}
<label style={S.label}>
ملاحظات (اختياري)
<textarea
style={{
...S.input,
height: 80,
padding: "0.5rem 0.75rem",
resize: "vertical",
}}
value={form.notes}
onChange={set("notes")}
placeholder="أي معلومات إضافية عن العميل…"
dir="rtl" 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> </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 <div
style={{ style={{
display: "flex", display: "flex",
@@ -305,7 +301,7 @@ export function ClientFormModal({
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={saving} disabled={isSubmitting}
style={{ style={{
height: 40, height: 40,
padding: "0 1.25rem", padding: "0 1.25rem",
@@ -315,7 +311,7 @@ export function ClientFormModal({
fontSize: 13, fontSize: 13,
fontWeight: 600, fontWeight: 600,
color: "var(--color-text-secondary)", color: "var(--color-text-secondary)",
cursor: saving ? "not-allowed" : "pointer", cursor: isSubmitting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
}} }}
> >
@@ -323,27 +319,27 @@ export function ClientFormModal({
</button> </button>
<button <button
type="submit" type="submit"
disabled={saving} disabled={isSubmitting}
style={{ style={{
height: 40, height: 40,
padding: "0 1.5rem", padding: "0 1.5rem",
borderRadius: "var(--radius-md)", borderRadius: "var(--radius-md)",
border: "none", border: "none",
background: saving background: isSubmitting
? "var(--color-brand-400)" ? "var(--color-brand-400)"
: "var(--color-brand-600)", : "var(--color-brand-600)",
fontSize: 13, fontSize: 13,
fontWeight: 700, fontWeight: 700,
color: "#FFF", color: "#FFF",
cursor: saving ? "not-allowed" : "pointer", cursor: isSubmitting ? "not-allowed" : "pointer",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
}} }}
> >
{saving && <Spinner size="sm" className="text-white" />} {isSubmitting && <Spinner size="sm" className="text-white" />}
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة العميل" : "حفظ التغييرات"} {isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة العميل" : "حفظ التغييرات"}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,16 +1,32 @@
"use client"; "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 { Spinner } from "../UI";
import type { Client } from "@/src/types/client"; import type { Client } from "@/src/types/client";
// ── Address count badge ──────────────────────────────────────────────────── // ── Address count badge ────────────────────────────────────────────────────
function AddressBadge({ count }: { count: number }) { function AddressBadge({ count, onClick }: { count: number; onClick: () => void }) {
const hasAddr = count > 0; const hasAddr = count > 0;
return ( return (
<span <button
type="button"
onClick={(e) => { e.stopPropagation(); onClick(); }}
title="إدارة العناوين"
style={{ style={{
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: 4,
borderRadius: "var(--radius-full)", borderRadius: "var(--radius-full)",
border: hasAddr ? "1px solid #BFDBFE" : "1px solid var(--color-border)", border: hasAddr ? "1px solid #BFDBFE" : "1px solid var(--color-border)",
background: hasAddr ? "#EFF6FF" : "var(--color-surface-muted)", background: hasAddr ? "#EFF6FF" : "var(--color-surface-muted)",
@@ -18,10 +34,17 @@ function AddressBadge({ count }: { count: number }) {
fontSize: 11, fontSize: 11,
fontWeight: 600, fontWeight: 600,
color: hasAddr ? "#1D4ED8" : "var(--color-text-muted)", color: hasAddr ? "#1D4ED8" : "var(--color-text-muted)",
cursor: "pointer",
fontFamily: "var(--font-sans)",
transition: "opacity 150ms",
}} }}
> >
{count} {count === 1 ? "عنوان" : "عناوين"} {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({ function IconBtn({
onClick, onClick,
title, 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 ──────────────────────────────────────────── // ── Shared card & header styles ────────────────────────────────────────────
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", borderRadius: "var(--radius-xl)",
@@ -145,6 +395,17 @@ export function ClientTable({
onPageChange, onPageChange,
onManageAddresses, onManageAddresses,
}: ClientTableProps) { }: 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 ( return (
<div style={cardStyle}> <div style={cardStyle}>
{/* Column headers */} {/* Column headers */}
@@ -209,25 +470,44 @@ export function ClientTable({
) : ( ) : (
/* Data rows */ /* Data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> <ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{clients.map((c, i) => ( {clients.map((c, i) => {
<li const isExpanded = expandedId === c.id;
key={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={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
alignItems: "center", alignItems: "center",
gap: "0.5rem", gap: "0.5rem",
padding: "0.875rem 1.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)", borderBottom: isExpanded
background: ? "none"
i % 2 !== 0 : "1px solid var(--color-border)",
/* Highlight the expanded row with a brand-tinted background */
background: isExpanded
? "#EFF6FF"
: i % 2 !== 0
? "var(--color-surface-muted)" ? "var(--color-surface-muted)"
: "transparent", : "transparent",
/* Left accent border when expanded (RTL: border-right in visual) */
borderRight: isExpanded
? "3px solid var(--color-brand-600)"
: "3px solid transparent",
fontSize: 13, fontSize: 13,
cursor: "pointer", cursor: "pointer",
transition: "background 120ms, border-color 120ms",
outline: "none",
}} }}
onClick={() => onManageAddresses(c)}
title={`إدارة عناوين ${c.name}`}
> >
{/* Name */} {/* Name */}
<div> <div>
@@ -236,9 +516,29 @@ export function ClientTable({
fontWeight: 600, fontWeight: 600,
color: "var(--color-text-primary)", color: "var(--color-text-primary)",
margin: 0, margin: 0,
display: "flex",
alignItems: "center",
gap: 6,
}} }}
> >
{c.name} {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> </p>
{c.taxId && ( {c.taxId && (
<p <p
@@ -271,8 +571,14 @@ export function ClientTable({
{c.phone} {c.phone}
</span> </span>
{/* Address count */} {/*
<AddressBadge count={c.addresses?.length ?? 0} /> * 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 */} {/* Created at */}
<span <span
@@ -289,14 +595,14 @@ export function ClientTable({
})} })}
</span> </span>
{/* Actions */} {/* Row action buttons */}
<div <div
style={{ style={{
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
gap: 6, gap: 6,
}} }}
onClick={(e) => e.stopPropagation()} // prevent row navigation onClick={(e) => e.stopPropagation()}
> >
<IconBtn <IconBtn
title={`تعديل ${c.name}`} title={`تعديل ${c.name}`}
@@ -339,8 +645,27 @@ export function ClientTable({
</svg> </svg>
</IconBtn> </IconBtn>
</div> </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)}
/>
)}
</li> </li>
))} );
})}
</ul> </ul>
)} )}

View File

@@ -1,64 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import type { Notification } from "@/src/hooks/useClients";
interface ToastProps { // Re-export the canonical implementation so existing imports keep working.
notification: Notification | null; export { Toast } from "@/src/Components/UI";
} export type { ToastNotification as Notification } from "@/src/Components/UI";
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>
);
}

View File

@@ -1,5 +1,18 @@
/**
* 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 { ClientFormModal } from "./Clientformmodal";
export { ClientTable } from "./Clienttable"; export { ClientTable } from "./Clienttable";
export { AddressFormModal } from "./Addressformmodal"; export { AddressFormModal } from "../Client_Adress/Addressformmodal";
export { DeleteConfirmModal } from "./Deleteconfirmmodal"; 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"; 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"; "use client";
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { Alert, Spinner } from "../UI"; import { Alert, Spinner } from "../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { clientService } from "@/src/services/client.service"; import { clientService } from "@/src/services/client.service";
import { tripService } from "@/src/services/trip.service"; import { tripService } from "@/src/services/trip.service";
import { createOrderSchema, updateOrderSchema } from "@/src/validations/order.validation"; import {
createOrderSchema,
updateOrderSchema,
} from "@/src/validations/order.validation";
import type { ValidationError } from "yup"; import type { ValidationError } from "yup";
import type { import type {
Order, Order,
@@ -91,7 +93,11 @@ interface OrderFormModalProps {
// ── Component ──────────────────────────────────────────────────────────────── // ── Component ────────────────────────────────────────────────────────────────
export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalProps) { export function OrderFormModal({
editOrder,
onClose,
onSubmit,
}: OrderFormModalProps) {
const isNew = editOrder === null; const isNew = editOrder === null;
// ── Relation data — clients & trips loaded once on mount ───────────────── // ── Relation data — clients & trips loaded once on mount ─────────────────
@@ -100,9 +106,15 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
const [relLoading, setRelLoading] = useState(true); const [relLoading, setRelLoading] = useState(true);
// ── Form state ──────────────────────────────────────────────────────────── // ── Form state ────────────────────────────────────────────────────────────
const [shipmentNumber, setShipmentNumber] = useState(editOrder?.shipmentNumber ?? ""); const [shipmentNumber, setShipmentNumber] = useState(
const [recipientName, setRecipientName] = useState(editOrder?.recipientName ?? ""); editOrder?.shipmentNumber ?? "",
const [recipientPhone, setRecipientPhone] = useState(editOrder?.recipientPhone ?? ""); );
const [recipientName, setRecipientName] = useState(
editOrder?.recipientName ?? "",
);
const [recipientPhone, setRecipientPhone] = useState(
editOrder?.recipientPhone ?? "",
);
const [clientId, setClientId] = useState(editOrder?.clientId ?? ""); const [clientId, setClientId] = useState(editOrder?.clientId ?? "");
const [tripId, setTripId] = useState(editOrder?.tripId ?? ""); const [tripId, setTripId] = useState(editOrder?.tripId ?? "");
const [type, setType] = useState(editOrder?.type ?? ""); const [type, setType] = useState(editOrder?.type ?? "");
@@ -125,12 +137,24 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
// ── Address state ───────────────────────────────────────────────────────── // ── Address state ─────────────────────────────────────────────────────────
// Delivery address (عنوان التسليم) — always creates new MongoDB doc // Delivery address (عنوان التسليم) — always creates new MongoDB doc
const [delCity, setDelCity] = useState(editOrder?.deliveryAddress?.details?.city ?? ""); const [delCity, setDelCity] = useState(
const [delDistrict, setDelDistrict] = useState(editOrder?.deliveryAddress?.details?.district ?? ""); editOrder?.deliveryAddress?.details?.city ?? "",
const [delStreet, setDelStreet] = useState(editOrder?.deliveryAddress?.details?.street ?? ""); );
const [delBuildingNo, setDelBuildingNo] = useState(editOrder?.deliveryAddress?.details?.buildingNo ?? ""); const [delDistrict, setDelDistrict] = useState(
const [delUnitNo, setDelUnitNo] = useState(editOrder?.deliveryAddress?.details?.unitNo ?? ""); editOrder?.deliveryAddress?.details?.district ?? "",
const [delZipCode, setDelZipCode] = useState(editOrder?.deliveryAddress?.details?.zipCode ?? ""); );
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 // GeoJSON coordinates [longitude, latitude] — required by backend
const [delLng, setDelLng] = useState( const [delLng, setDelLng] = useState(
editOrder?.deliveryAddress?.location?.coordinates?.[0] != null editOrder?.deliveryAddress?.location?.coordinates?.[0] != null
@@ -147,13 +171,27 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
const [pickupMode, setPickupMode] = useState<"id" | "new">( const [pickupMode, setPickupMode] = useState<"id" | "new">(
editOrder?.pickupAddressId ? "id" : "new", editOrder?.pickupAddressId ? "id" : "new",
); );
const [pickupAddressId, setPickupAddressId] = useState(editOrder?.pickupAddressId ?? ""); const [pickupAddressId, setPickupAddressId] = useState(
const [pkpCity, setPkpCity] = useState(editOrder?.pickupAddress?.details?.city ?? ""); editOrder?.pickupAddressId ?? "",
const [pkpDistrict, setPkpDistrict] = useState(editOrder?.pickupAddress?.details?.district ?? ""); );
const [pkpStreet, setPkpStreet] = useState(editOrder?.pickupAddress?.details?.street ?? ""); const [pkpCity, setPkpCity] = useState(
const [pkpBuildingNo, setPkpBuildingNo] = useState(editOrder?.pickupAddress?.details?.buildingNo ?? ""); editOrder?.pickupAddress?.details?.city ?? "",
const [pkpUnitNo, setPkpUnitNo] = useState(editOrder?.pickupAddress?.details?.unitNo ?? ""); );
const [pkpZipCode, setPkpZipCode] = useState(editOrder?.pickupAddress?.details?.zipCode ?? ""); 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 // GeoJSON coordinates [longitude, latitude] — optional for pickup
const [pkpLng, setPkpLng] = useState( const [pkpLng, setPkpLng] = useState(
editOrder?.pickupAddress?.location?.coordinates?.[0] != null editOrder?.pickupAddress?.location?.coordinates?.[0] != null
@@ -166,7 +204,6 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
: "", : "",
); );
const [errors, setErrors] = useState<OrderSchemaErrors>({}); const [errors, setErrors] = useState<OrderSchemaErrors>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");
@@ -185,7 +222,10 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
const clientList: Client[] = clientPayload.data ?? []; const clientList: Client[] = clientPayload.data ?? [];
// Fetch first 100 trips (active ones the order can be assigned to) // 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 tripPayload = (tripRes as any).data ?? tripRes;
const tripList: Trip[] = tripPayload.data ?? []; 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. // links — but now they also see a message explaining why.
console.error("OrderFormModal: failed to load clients/trips", err); console.error("OrderFormModal: failed to load clients/trips", err);
if (!cancelled) { if (!cancelled) {
setApiError("تعذّر تحميل قوائم العملاء والرحلات. تحقق من اتصالك وحاول إعادة فتح النافذة."); setApiError(
"تعذّر تحميل قوائم العملاء والرحلات. تحقق من اتصالك وحاول إعادة فتح النافذة.",
);
} }
} finally { } finally {
if (!cancelled) setRelLoading(false); if (!cancelled) setRelLoading(false);
} }
})(); })();
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, []); }, []);
// ── Keyboard / focus setup ──────────────────────────────────────────────── // ── Keyboard / focus setup ────────────────────────────────────────────────
useEffect(() => { firstRef.current?.focus(); }, []);
useEffect(() => { 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); window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h); return () => window.removeEventListener("keydown", h);
}, [onClose]); }, [onClose]);
@@ -221,14 +269,13 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
// ── Dynamic input error style ───────────────────────────────────────────── // ── Dynamic input error style ─────────────────────────────────────────────
const inputStyle = (field: keyof OrderSchemaErrors): React.CSSProperties => ({ const inputStyle = (field: keyof OrderSchemaErrors): React.CSSProperties => ({
...inputBase, ...inputBase,
border: errors[field] border: errors[field] ? "1px solid var(--color-danger)" : inputBase.border,
? "1px solid var(--color-danger)"
: inputBase.border,
...(errors[field] ? { background: "#FEF2F2" } : {}), ...(errors[field] ? { background: "#FEF2F2" } : {}),
}); });
const clearFieldError = useCallback( const clearFieldError = useCallback(
(field: keyof OrderSchemaErrors) => setErrors((p) => ({ ...p, [field]: undefined })), (field: keyof OrderSchemaErrors) =>
setErrors((p) => ({ ...p, [field]: undefined })),
[], [],
); );
@@ -263,11 +310,26 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
setErrors(bag); setErrors(bag);
return false; 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 ──────────────────────────────────────────────────────────────── // ── Submit ────────────────────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
console.log("her");
e.preventDefault(); e.preventDefault();
const valid = await runValidation(); const valid = await runValidation();
@@ -343,12 +405,14 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
} }
} }
setSaving(true); setSaving(true);
setApiError(""); setApiError("");
try { try {
const ok = await onSubmit(payload as unknown as CreateOrderPayload, isNew); const ok = await onSubmit(
payload as unknown as CreateOrderPayload,
isNew,
);
if (ok) { if (ok) {
onClose(); onClose();
} else { } else {
@@ -356,7 +420,9 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
} }
} catch (err) { } catch (err) {
const message = const message =
err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."; err instanceof Error
? err.message
: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.";
setApiError(message); setApiError(message);
} finally { } finally {
setSaving(false); setSaving(false);
@@ -369,57 +435,88 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="order-modal-title" aria-labelledby="order-modal-title"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{ style={{
position: "fixed", inset: 0, zIndex: 50, position: "fixed",
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", inset: 0,
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 50,
padding: "1rem", overflowY: "auto", background: "rgba(15,23,42,0.55)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
overflowY: "auto",
}} }}
> >
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
width: "100%", maxWidth: 640, width: "100%",
maxWidth: 640,
background: "var(--color-surface)", background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)", borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)", boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden", margin: "auto", overflow: "hidden",
margin: "auto",
}} }}
> >
{/* ── Header ── */} {/* ── Header ── */}
<div style={{ <div
display: "flex", alignItems: "center", justifyContent: "space-between", style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "1.25rem 1.5rem", padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)", borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)", background: "var(--color-surface-muted)",
}}> }}
>
<div> <div>
<p style={{ <p
fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", style={{
color: "#2563EB", fontWeight: 600, margin: 0, fontSize: 11,
}}> letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
margin: 0,
}}
>
{isNew ? "إنشاء طلب" : "تعديل طلب"} {isNew ? "إنشاء طلب" : "تعديل طلب"}
</p> </p>
<h2 <h2
id="order-modal-title" id="order-modal-title"
style={{ style={{
fontSize: 17, fontWeight: 700, fontSize: 17,
fontWeight: 700,
color: "var(--color-text-primary)", color: "var(--color-text-primary)",
margin: "4px 0 0", fontFamily: "var(--font-mono)", margin: "4px 0 0",
fontFamily: "var(--font-mono)",
}} }}
> >
{isNew ? "طلب جديد" : editOrder?.shipmentNumber} {isNew ? "طلب جديد" : editOrder?.shipmentNumber}
</h2> </h2>
</div> </div>
<button <button
type="button" onClick={onClose} aria-label="إغلاق" type="button"
onClick={onClose}
aria-label="إغلاق"
style={{ style={{
width: 34, height: 34, borderRadius: "var(--radius-md)", width: 34,
border: "1px solid var(--color-border)", background: "var(--color-surface)", height: 34,
cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", borderRadius: "var(--radius-md)",
display: "flex", alignItems: "center", justifyContent: "center", 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 noValidate
style={{ style={{
padding: "1.5rem", padding: "1.5rem",
display: "flex", flexDirection: "column", gap: "1rem", display: "flex",
maxHeight: "75vh", overflowY: "auto", flexDirection: "column",
gap: "1rem",
maxHeight: "75vh",
overflowY: "auto",
}} }}
dir="rtl" dir="rtl"
> >
{apiError && ( {apiError && (
<Alert type="error" message={apiError} onClose={() => setApiError("")} /> <Alert
type="error"
message={apiError}
onClose={() => setApiError("")}
/>
)} )}
{/* ── Section: Shipment & Recipient ── */} {/* ── Section: Shipment & Recipient ── */}
<p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p> <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 */} {/* Shipment number — required */}
<label style={labelStyle}> <label style={labelStyle}>
رقم الشحنة * رقم الشحنة *
@@ -498,9 +607,19 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
<span style={errorTextStyle}>{errors.clientId}</span> <span style={errorTextStyle}>{errors.clientId}</span>
)} )}
{/* Helper link */} {/* Helper link */}
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}> <Link
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" href="/dashboard/clients"
stroke="currentColor" strokeWidth="2.5"> 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="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
@@ -576,9 +695,19 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
<span style={errorTextStyle}>{errors.tripId}</span> <span style={errorTextStyle}>{errors.tripId}</span>
)} )}
{/* Helper link */} {/* Helper link */}
<Link href="/dashboard/trips" style={createLinkStyle} tabIndex={-1}> <Link
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" href="/dashboard/trips"
stroke="currentColor" strokeWidth="2.5"> 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="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
@@ -590,7 +719,13 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
{/* ── Section: Shipment details ── */} {/* ── Section: Shipment details ── */}
<p style={sectionHeadingStyle}>تفاصيل الشحنة</p> <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 */} {/* Type — optional */}
<label style={labelStyle}> <label style={labelStyle}>
نوع الشحنة نوع الشحنة
@@ -642,7 +777,13 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
{/* ── Section: Payment ── */} {/* ── Section: Payment ── */}
<p style={sectionHeadingStyle}>بيانات الدفع</p> <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 */} {/* Subtotal — optional */}
<label style={labelStyle}> <label style={labelStyle}>
الإجمالي الفرعي الإجمالي الفرعي
@@ -692,7 +833,9 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
<select <select
style={{ ...inputBase, cursor: "pointer" }} style={{ ...inputBase, cursor: "pointer" }}
value={paymentMethod} value={paymentMethod}
onChange={(e) => setPaymentMethod(e.target.value as PaymentMethod | "")} onChange={(e) =>
setPaymentMethod(e.target.value as PaymentMethod | "")
}
dir="rtl" dir="rtl"
> >
<option value="">اختر الطريقة</option> <option value="">اختر الطريقة</option>
@@ -726,36 +869,78 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
{/* ── Section: Delivery Address ── */} {/* ── Section: Delivery Address ── */}
<p style={sectionHeadingStyle}>عنوان التسليم</p> <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}> <label style={labelStyle}>
المدينة المدينة
<span style={optionalLabelStyle}>(اختياري)</span> <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>
<label style={labelStyle}> <label style={labelStyle}>
الحي الحي
<span style={optionalLabelStyle}>(اختياري)</span> <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>
<label style={labelStyle}> <label style={labelStyle}>
الشارع الشارع
<span style={optionalLabelStyle}>(اختياري)</span> <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>
<label style={labelStyle}> <label style={labelStyle}>
رقم المبنى رقم المبنى
<span style={optionalLabelStyle}>(اختياري)</span> <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>
<label style={labelStyle}> <label style={labelStyle}>
رقم الوحدة رقم الوحدة
<span style={optionalLabelStyle}>(اختياري)</span> <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>
<label style={labelStyle}> <label style={labelStyle}>
الرمز البريدي الرمز البريدي
<span style={optionalLabelStyle}>(اختياري)</span> <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> </label>
{/* GeoJSON coordinates — required by backend */} {/* GeoJSON coordinates — required by backend */}
<label style={labelStyle}> <label style={labelStyle}>
@@ -788,20 +973,31 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
<p style={sectionHeadingStyle}>عنوان الاستلام</p> <p style={sectionHeadingStyle}>عنوان الاستلام</p>
{/* Toggle: existing ID or new address */} {/* 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) => ( {(["id", "new"] as const).map((m) => (
<button <button
key={m} key={m}
type="button" type="button"
onClick={() => setPickupMode(m)} onClick={() => setPickupMode(m)}
style={{ style={{
height: 32, padding: "0 0.875rem", height: 32,
padding: "0 0.875rem",
borderRadius: "var(--radius-md)", borderRadius: "var(--radius-md)",
border: `1px solid ${pickupMode === m ? "var(--color-brand-600)" : "var(--color-border)"}`, border: `1px solid ${pickupMode === m ? "var(--color-brand-600)" : "var(--color-border)"}`,
background: pickupMode === m ? "var(--color-brand-50, #EFF6FF)" : "var(--color-surface)", background:
fontSize: 12, fontWeight: 600, pickupMode === m
color: pickupMode === m ? "var(--color-brand-600)" : "var(--color-text-secondary)", ? "var(--color-brand-50, #EFF6FF)"
cursor: "pointer", fontFamily: "var(--font-sans)", : "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)" : "عنوان جديد"} {m === "id" ? "عنوان موجود (ID)" : "عنوان جديد"}
@@ -822,36 +1018,78 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
/> />
</label> </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}> <label style={labelStyle}>
المدينة المدينة
<span style={optionalLabelStyle}>(اختياري)</span> <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>
<label style={labelStyle}> <label style={labelStyle}>
الحي الحي
<span style={optionalLabelStyle}>(اختياري)</span> <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>
<label style={labelStyle}> <label style={labelStyle}>
الشارع الشارع
<span style={optionalLabelStyle}>(اختياري)</span> <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>
<label style={labelStyle}> <label style={labelStyle}>
رقم المبنى رقم المبنى
<span style={optionalLabelStyle}>(اختياري)</span> <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>
<label style={labelStyle}> <label style={labelStyle}>
رقم الوحدة رقم الوحدة
<span style={optionalLabelStyle}>(اختياري)</span> <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>
<label style={labelStyle}> <label style={labelStyle}>
الرمز البريدي الرمز البريدي
<span style={optionalLabelStyle}>(اختياري)</span> <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> </label>
{/* GeoJSON coordinates — optional for pickup */} {/* GeoJSON coordinates — optional for pickup */}
<label style={labelStyle}> <label style={labelStyle}>
@@ -884,20 +1122,27 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
)} )}
{/* ── Actions ── */} {/* ── Actions ── */}
<div style={{ <div
display: "flex", gap: "0.5rem", style={{
justifyContent: "flex-end", paddingTop: "0.5rem", display: "flex",
}}> gap: "0.5rem",
justifyContent: "flex-end",
paddingTop: "0.5rem",
}}
>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={saving} disabled={saving}
style={{ style={{
height: 40, padding: "0 1.25rem", height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)", borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", 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", cursor: saving ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
}} }}
@@ -907,14 +1152,22 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
onClick={() => console.log("BUTTON CLICKED DIRECTLY")}
style={{ style={{
height: 40, padding: "0 1.5rem", height: 40,
padding: "0 1.5rem",
borderRadius: "var(--radius-md)", borderRadius: "var(--radius-md)",
border: "none", border: "none",
background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", background: saving
fontSize: 13, fontWeight: 700, color: "#FFF", ? "var(--color-brand-400)"
: "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: saving ? "not-allowed" : "pointer", cursor: saving ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", gap: 8, display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
}} }}
> >

View File

@@ -5,11 +5,10 @@ import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { clientAddressService } from "@/src/services/clientAddress.service"; import { clientAddressService } from "@/src/services/clientAddress.service";
import type { import type {
ClientAddress, CreateAddressFormValues,
ClientAddressFormData, UpdateAddressFormValues,
AddressTableAction, } from "@/src/validations/client_address.validator";
AddressTableState, import { AddressTableAction, AddressTableState } from "../types/client";
} from "@/src/types/client";
// ── Notification ─────────────────────────────────────────────────────────── // ── Notification ───────────────────────────────────────────────────────────
export interface Notification { export interface Notification {
@@ -90,7 +89,7 @@ export function useClientAddresses(clientId: string) {
// ── CREATE ─────────────────────────────────────────────────────────────── // ── CREATE ───────────────────────────────────────────────────────────────
const createAddress = useCallback( const createAddress = useCallback(
async (data: ClientAddressFormData): Promise<boolean> => { async (data: CreateAddressFormValues): Promise<boolean> => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
const res = await clientAddressService.create(clientId, data, token); const res = await clientAddressService.create(clientId, data, token);
@@ -110,7 +109,7 @@ export function useClientAddresses(clientId: string) {
// ── UPDATE ─────────────────────────────────────────────────────────────── // ── UPDATE ───────────────────────────────────────────────────────────────
const updateAddress = useCallback( const updateAddress = useCallback(
async (id: string, data: ClientAddressFormData): Promise<boolean> => { async (id: string, data: UpdateAddressFormValues): Promise<boolean> => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
const res = await clientAddressService.update(clientId, id, data, token); const res = await clientAddressService.update(clientId, id, data, token);

View File

@@ -1,49 +1,86 @@
// Mirrors user.service.ts pattern: explicit token param, buildPayload helper.
import { get, post, put, patch, del } from "./api"; import { get, post, put, patch, del } from "./api";
import type { ApiResponse, ClientAddress } from "@/src/types/client";
import type { import type {
ApiResponse, CreateAddressFormValues,
ClientAddress, UpdateAddressFormValues,
ClientAddressFormData, } from "@/src/validations/client_address.validator";
} from "@/src/types/client";
const base = (clientId: string) => `client/${clientId}/addresses`; // FIX: if clientId is undefined/empty, this used to silently build a broken
// URL like "client/undefined/addresses" which 404s with no clear error.
// Now it throws a clear error pointing at the real problem (missing clientId).
const base = (clientId: string) => {
if (!clientId) {
throw new Error("clientAddressService: clientId is required but was empty/undefined. Check useParams() key matches your route folder name.");
}
return `client/${clientId}/addresses`;
};
// FIX: same idea as base() above — fail clearly if addressId is missing,
// instead of building "client/123/addresses/undefined"
const withAddressId = (clientId: string, addressId: string) => {
if (!addressId) {
throw new Error("clientAddressService: addressId is required but was empty/undefined.");
}
return `${base(clientId)}/${addressId}`;
};
export const clientAddressService = { export const clientAddressService = {
/** جلب جميع عناوين عميل معين */ /** Get all addresses for a client */
getAll: (clientId: string, token: string | null) => getAll: (clientId: string, token: string | null) =>
get<ApiResponse<ClientAddress[]>>(base(clientId), token), get<ApiResponse<ClientAddress[]>>(base(clientId), token),
/** جلب عنوان واحد بالـ ID */ /** Get a single address by id */
getById: (clientId: string, addressId: string, token: string | null) => getById: (clientId: string, addressId: string, token: string | null) =>
get<ApiResponse<ClientAddress>>(`${base(clientId)}/${addressId}`, token), get<ApiResponse<ClientAddress>>(withAddressId(clientId, addressId), token),
/** إضافة عنوان جديد لعميل */ /** Create a new address */
create: (clientId: string, data: ClientAddressFormData, token: string | null) => create: (
post<ApiResponse<ClientAddress>>(base(clientId), buildPayload(clientId, data), token), clientId: string,
data: CreateAddressFormValues,
token: string | null
) =>
post<ApiResponse<ClientAddress>>(base(clientId), buildPayload(data), token),
/** تعديل عنوان موجود */ /** Update an existing address */
update: (clientId: string, addressId: string, data: ClientAddressFormData, token: string | null) => update: (
put<ApiResponse<ClientAddress>>(`${base(clientId)}/${addressId}`, buildPayload(clientId, data), token), clientId: string,
addressId: string,
data: UpdateAddressFormValues,
token: string | null
) =>
put<ApiResponse<ClientAddress>>(
withAddressId(clientId, addressId),
buildPayload(data),
token
),
/** حذف عنوان */ /** Delete an address */
delete: (clientId: string, addressId: string, token: string | null) => delete: (clientId: string, addressId: string, token: string | null) =>
del<void>(`${base(clientId)}/${addressId}`, token), del<void>(withAddressId(clientId, addressId), token),
/** تعيين عنوان كعنوان رئيسي — الباكند يُلغي الاختيار عن الباقين */ /** Promote an address to primary (backend demotes all others) */
setPrimary: (clientId: string, addressId: string, token: string | null) => setPrimary: (clientId: string, addressId: string, token: string | null) =>
patch<ApiResponse<ClientAddress>>(`${base(clientId)}/${addressId}/primary`, {}, token), patch<ApiResponse<ClientAddress>>(
`${withAddressId(clientId, addressId)}/primary`,
{},
token
),
}; };
/** تحويل بيانات النموذج إلى payload */ /**
function buildPayload(clientId: string, data: ClientAddressFormData): Record<string, unknown> { * Maps the validated form shape (nested) directly to the API payload.
* FIX 1: No more .trim() calls on flat fields that no longer exist —
* the validator already trims every string field before we reach here.
*/
function buildPayload(
data: CreateAddressFormValues | UpdateAddressFormValues
): Record<string, unknown> {
return { return {
clientId, label: data.label,
label: data.label.trim(), branchName: data.branchName,
street: data.street.trim(),
city: data.city.trim(),
state: data.state.trim(),
postalCode: data.postalCode.trim(),
country: data.country.trim(),
isPrimary: data.isPrimary, isPrimary: data.isPrimary,
contactPerson: data.contactPerson,
details: data.details,
location: data.location,
}; };
} }

View File

@@ -12,16 +12,47 @@ export interface Client {
addresses?: ClientAddress[]; addresses?: ClientAddress[];
} }
// FIX: this interface was flat (street, city, state... directly on the object)
// but the real API / form / validator use a NESTED shape (details, contactPerson,
// location). Updated to match what Addressformmodal.tsx actually reads
// (editAddress?.details?.city, editAddress?.contactPerson?.phone, etc).
export interface ClientAddress { export interface ClientAddress {
id: string; id: string;
clientId: string; clientId: string;
label: string; // e.g. "Billing", "Shipping", "HQ" label: string; // e.g. "Billing", "Shipping", "HQ"
street: string;
city: string; // was missing before — needed because the form reads editAddress?.branchName
state: string; branchName: string | null;
postalCode: string;
// was missing before — needed because the form reads editAddress?.contactPerson?.name/phone
contactPerson?: {
name?: string;
phone?: string;
};
// was flat (street/city/state/postalCode/country) — now nested under "details"
// to match details.city, details.street, etc. used in the form and validator
details: {
country: string; country: string;
city: string;
state?: string;
district?: string;
street: string;
buildingNo?: string;
unitNo?: string;
additionalNo?: string;
zipCode?: string;
apartment?: string;
};
// was missing before — needed because the form reads
// editAddress?.location?.coordinates
location: {
coordinates: [number, number]; // [longitude, latitude]
};
isPrimary: boolean; isPrimary: boolean;
isValidated?: boolean; // backend-only flag used by updateAddressSchema
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -39,12 +70,17 @@ export type UpdateClientAddressPayload = Partial<CreateClientAddressPayload>;
// ─── Form & Validation ──────────────────────────────────────────────────────── // ─── Form & Validation ────────────────────────────────────────────────────────
// FIX: taxId and notes are optional in the actual client form (yup .optional()),
// but this interface marked them as required strings. That mismatch is what
// caused the "Property 'taxId' is missing" type error on onSubmit.
// NOTE: if your form schema truly requires these fields, do the opposite —
// remove the "?" here AND make sure the yup schema uses .required() too.
export interface ClientFormData { export interface ClientFormData {
name: string; name: string;
email: string; email: string;
phone: string; phone: string;
taxId: string; taxId?: string;
notes: string; notes?: string;
} }
export interface ClientFormErrors { export interface ClientFormErrors {

View File

@@ -0,0 +1,87 @@
import * as yup from "yup";
// ─── Saudi phone regex ───────────────────────────
// accept: +966 5xxxxxxxx | 966 5xxxxxxxx | 05xxxxxxxx | 5xxxxxxxx
const SAUDI_PHONE_REGEX = /^(\+966|966|0)?5[0-9]{8}$/;
const SAUDI_PHONE_MSG = "صيغة الهاتف غير صحيحة (مثال: 05xxxxxxxx)";
// ─── clientType values enums ────────────────────────────
export const CLIENT_TYPES = ["Individual", "Corporate"] as const;
export type ClientType = typeof CLIENT_TYPES[number];
// ─────────────────────────────────────────────────────────────────────────────
// Inferred TypeScript types
// ─────────────────────────────────────────────────────────────────────────────
// data type create fprm
export type CreateClientFormValues = yup.InferType<typeof createClientSchema>;
// data type update form
export type UpdateClientFormValues = yup.InferType<typeof updateClientSchema>;
// ─────────────────────────────────────────────────────────────────────────────
// createClientSchema
export const createClientSchema = yup.object({
name: yup
.string()
.trim()
.required("اسم العميل مطلوب")
.min(2, "الاسم يجب أن يكون حرفين على الأقل"),
phone: yup
.string()
.trim()
.required("رقم الهاتف مطلوب")
.matches(SAUDI_PHONE_REGEX, SAUDI_PHONE_MSG),
email: yup
.string()
.trim()
.email("صيغة البريد الإلكتروني غير صحيحة")
.optional()
.transform((val) => (val === "" ? undefined : val)),
clientType: yup
.mixed<ClientType>()
.oneOf([...CLIENT_TYPES] as ClientType[], "نوع العميل غير صالح")
.optional(),
}).required();
// ─────────────────────────────────────────────────────────────────────────────
// updateClientSchema
export const updateClientSchema = yup.object({
name: yup
.string()
.trim()
.min(2, "الاسم يجب أن يكون حرفين على الأقل")
.optional()
.transform((val) => (val === "" ? undefined : val)),
phone: yup
.string()
.trim()
.matches(SAUDI_PHONE_REGEX, {
message: SAUDI_PHONE_MSG,
excludeEmptyString: true,
})
.optional()
.transform((val) => (val === "" ? undefined : val)),
email: yup
.string()
.trim()
.email("صيغة البريد الإلكتروني غير صحيحة")
.optional()
.transform((val) => (val === "" ? undefined : val)),
clientType: yup
.mixed<ClientType>()
.oneOf([...CLIENT_TYPES] as ClientType[], "نوع العميل غير صالح")
.optional(),
isActive: yup
.boolean()
.optional(),
}).required();

View File

@@ -0,0 +1,204 @@
import * as yup from "yup";
// ─── Saudi phone regex ───────────────────────────
const SAUDI_PHONE_REGEX = /^(\+966|966|0)?5[0-9]{8}$/;
const SAUDI_PHONE_MSG = "صيغة الهاتف غير صحيحة (مثال: 05xxxxxxxx)";
// ─────────────────────────────────────────────────────────────────────────────
// Inferred TypeScript types
// ─────────────────────────────────────────────────────────────────────────────
export type CreateAddressFormValues = yup.InferType<typeof createAddressSchema>;
export type UpdateAddressFormValues = yup.InferType<typeof updateAddressSchema>;
// ─────────────────────────────────────────────────────────────────────────────
// Sub-schemas (shared between create & update)
// ─────────────────────────────────────────────────────────────────────────────
/**
* contactPersonSchema
*/
const contactPersonSchema = yup.object({
name: yup
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
phone: yup
.string()
.trim()
.matches(SAUDI_PHONE_REGEX, {
message: SAUDI_PHONE_MSG,
excludeEmptyString: true,
})
.optional()
.transform((v) => (v === "" ? undefined : v)),
}).optional();
/**
* detailsCreateSchema
*/
const detailsCreateSchema = yup.object({
country: yup
.string()
.trim()
.optional()
.default("SA"),
city: yup
.string()
.trim()
.required("المدينة مطلوبة"),
state: yup
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
district: yup
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
street: yup
.string()
.trim()
.required("الشارع / العنوان التفصيلي مطلوب"),
buildingNo: yup
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
unitNo: yup
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
additionalNo: yup
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
zipCode: yup
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
apartment: yup
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
}).required();
/**
* detailsUpdateSchema
*/
const detailsUpdateSchema = yup.object({
country: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
city: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
state: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
district: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
street: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
buildingNo: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
unitNo: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
additionalNo:yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
zipCode: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
apartment: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
}).optional();
/**
* locationCreateSchema
*/
const locationCreateSchema = yup.object({
coordinates: yup
.array()
.of(yup.number().required())
.length(2, "الإحداثيات يجب أن تكون [خط الطول، خط العرض]")
.required("إحداثيات الموقع مطلوبة"),
}).required();
/**
* locationUpdateSchema
*/
const locationUpdateSchema = yup.object({
coordinates: yup
.array()
.of(yup.number().required())
.length(2, "الإحداثيات يجب أن تكون [خط الطول، خط العرض]")
.optional(),
}).optional();
// ─────────────────────────────────────────────────────────────────────────────
// createAddressSchema
// ─────────────────────────────────────────────────────────────────────────────
export const createAddressSchema = yup.object({
label: yup
.string()
.trim()
.optional()
.default("General"),
branchName: yup
.string()
.trim()
.nullable()
.optional()
.transform((v) => (v === "" ? null : v)),
contactPerson: contactPersonSchema,
details: detailsCreateSchema,
location: locationCreateSchema,
isPrimary: yup
.boolean()
.optional()
.default(false),
}).required();
// ─────────────────────────────────────────────────────────────────────────────
// updateAddressSchema
// ─────────────────────────────────────────────────────────────────────────────
export const updateAddressSchema = yup.object({
label: yup
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
branchName: yup
.string()
.trim()
.nullable()
.optional()
.transform((v) => (v === "" ? null : v)),
contactPerson: contactPersonSchema,
details: detailsUpdateSchema,
location: locationUpdateSchema,
// isValidated — backend-only field،
isValidated: yup
.boolean()
.optional(),
// isPrimary — frontend-only
isPrimary: yup
.boolean()
.optional(),
}).required();

View File

@@ -5,7 +5,20 @@ const SAUDI_PHONE_RE = /^(05\d{8}|009665\d{8}|\+9665\d{8})$/;
// ── Address sub-schemas (mirrors backend orderAddressSchema) ───────────────── // ── Address sub-schemas (mirrors backend orderAddressSchema) ─────────────────
// Delivery address — location.coordinates required by the backend (MongoDB GeoJSON) // Delivery address — coordinates SHOULD be required by the backend (MongoDB
// GeoJSON), but they must NOT be marked .required() at the yup-schema level
// here. yup.object({...}).optional() on the parent does not skip validating
// .required() fields nested two levels down when the parent key is missing
// from the payload entirely — yup still walks into the nested object schema
// and fails on the inner .required(), even though `deliveryAddress` was never
// sent in the validated value. (Confirmed by direct testing: a payload with
// no `deliveryAddress` key at all still threw "إحداثيات موقع التسليم مطلوبة"
// before this fix — which is exactly why handleSubmit appeared to silently
// do nothing on every submit once this field was added.)
//
// Real requiredness (only require coordinates once the user is actually
// filling in a delivery address) is enforced in OrderFormModal.tsx instead,
// right before building the payload.
const deliveryAddressSchema = yup.object({ const deliveryAddressSchema = yup.object({
details: yup.object({ details: yup.object({
city: yup.string().trim().optional(), city: yup.string().trim().optional(),
@@ -21,8 +34,8 @@ const deliveryAddressSchema = yup.object({
.array() .array()
.of(yup.number().required()) .of(yup.number().required())
.length(2, "الإحداثيات يجب أن تكون [خط الطول, خط العرض]") .length(2, "الإحداثيات يجب أن تكون [خط الطول, خط العرض]")
.required("إحداثيات موقع التسليم مطلوبة"), .optional(),
}).required("موقع التسليم مطلوب"), }).optional(),
}).optional(); }).optional();
// Pickup address — coordinates optional (user may supply an existing ID instead) // Pickup address — coordinates optional (user may supply an existing ID instead)