update order pages and create client pages also client adresses pages
This commit is contained in:
@@ -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,34 +796,80 @@ export default function ClientAddressesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add address button */}
|
{/* Header action buttons */}
|
||||||
<button
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
type="button"
|
{/* Edit client (opens compound modal with address section) */}
|
||||||
onClick={() => setFormTarget(null)}
|
{client && (
|
||||||
style={{
|
<button
|
||||||
height: 40,
|
type="button"
|
||||||
padding: "0 1.125rem",
|
onClick={() => setEditingClient(true)}
|
||||||
borderRadius: "var(--radius-lg)",
|
style={{
|
||||||
border: "none",
|
height: 40,
|
||||||
background: "var(--color-brand-600)",
|
padding: "0 1rem",
|
||||||
fontSize: 13,
|
borderRadius: "var(--radius-lg)",
|
||||||
fontWeight: 700,
|
border: "1px solid var(--color-border)",
|
||||||
color: "#FFF",
|
background: "var(--color-surface)",
|
||||||
cursor: "pointer",
|
fontSize: 13,
|
||||||
display: "inline-flex",
|
fontWeight: 600,
|
||||||
alignItems: "center",
|
color: "var(--color-text-secondary)",
|
||||||
gap: 7,
|
cursor: "pointer",
|
||||||
fontFamily: "var(--font-sans)",
|
display: "inline-flex",
|
||||||
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
|
alignItems: "center",
|
||||||
whiteSpace: "nowrap",
|
gap: 7,
|
||||||
}}
|
fontFamily: "var(--font-sans)",
|
||||||
>
|
}}
|
||||||
<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" />
|
<svg
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
width="13"
|
||||||
</svg>
|
height="13"
|
||||||
إضافة عنوان
|
viewBox="0 0 24 24"
|
||||||
</button>
|
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 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddrFormTarget(null)}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.125rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "none",
|
||||||
|
background: "var(--color-brand-600)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 7,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
إضافة عنوان
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Client info strip */}
|
{/* Client info strip */}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
"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 ──────────────────────────────────────────────────────────
|
||||||
import { useClients } from "@/src/hooks/useClients";
|
// 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 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";
|
||||||
import { ClientTable } from "@/src/Components/Client/Clienttable";
|
import { ClientTable } from "@/src/Components/Client/Clienttable";
|
||||||
import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
|
import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
|
||||||
|
|
||||||
export default function ClientsPage() {
|
export default function ClientsPage() {
|
||||||
@@ -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
36
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -1,392 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { Alert, Spinner } from "../UI";
|
|
||||||
import type {
|
|
||||||
ClientAddress,
|
|
||||||
ClientAddressFormData,
|
|
||||||
ClientAddressFormErrors,
|
|
||||||
} from "@/src/types/client";
|
|
||||||
|
|
||||||
// ── Fixed styles ───────────────────────────────────────────────────────────
|
|
||||||
const S = {
|
|
||||||
input: {
|
|
||||||
width: "100%",
|
|
||||||
height: 40,
|
|
||||||
padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
} as React.CSSProperties,
|
|
||||||
label: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column" as const,
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
} as React.CSSProperties,
|
|
||||||
errorText: {
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-danger)",
|
|
||||||
fontWeight: 500,
|
|
||||||
} as React.CSSProperties,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Validation ─────────────────────────────────────────────────────────────
|
|
||||||
function validate(data: ClientAddressFormData): ClientAddressFormErrors {
|
|
||||||
const e: ClientAddressFormErrors = {};
|
|
||||||
if (!data.label.trim()) e.label = "النوع مطلوب";
|
|
||||||
if (!data.street.trim()) e.street = "العنوان مطلوب";
|
|
||||||
if (!data.city.trim()) e.city = "المدينة مطلوبة";
|
|
||||||
if (!data.state.trim()) e.state = "المنطقة مطلوبة";
|
|
||||||
if (!data.postalCode.trim()) e.postalCode = "الرمز البريدي مطلوب";
|
|
||||||
if (!data.country.trim()) e.country = "الدولة مطلوبة";
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LABEL_PRESETS = ["فوترة", "شحن", "المقر الرئيسي", "فرع", "مستودع", "أخرى"];
|
|
||||||
|
|
||||||
// ── Props ──────────────────────────────────────────────────────────────────
|
|
||||||
interface AddressFormModalProps {
|
|
||||||
editAddress: ClientAddress | null; // null = create mode
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (data: ClientAddressFormData, isNew: boolean) => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────────────
|
|
||||||
export function AddressFormModal({
|
|
||||||
editAddress,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
}: AddressFormModalProps) {
|
|
||||||
const isNew = editAddress === null;
|
|
||||||
|
|
||||||
const [form, setForm] = useState<ClientAddressFormData>({
|
|
||||||
label: editAddress?.label ?? "",
|
|
||||||
street: editAddress?.street ?? "",
|
|
||||||
city: editAddress?.city ?? "",
|
|
||||||
state: editAddress?.state ?? "",
|
|
||||||
postalCode: editAddress?.postalCode ?? "",
|
|
||||||
country: editAddress?.country ?? "المملكة العربية السعودية",
|
|
||||||
isPrimary: editAddress?.isPrimary ?? false,
|
|
||||||
});
|
|
||||||
const [errors, setErrors] = useState<ClientAddressFormErrors>({});
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [apiError, setApiError] = useState("");
|
|
||||||
const firstRef = useRef<HTMLSelectElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => { firstRef.current?.focus(); }, []);
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
||||||
window.addEventListener("keydown", handler);
|
|
||||||
return () => window.removeEventListener("keydown", handler);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const set = (field: keyof ClientAddressFormData) =>
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
|
||||||
setForm((p) => ({ ...p, [field]: e.target.value }));
|
|
||||||
if (errors[field as keyof ClientAddressFormErrors])
|
|
||||||
setErrors((p) => ({ ...p, [field]: undefined }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const errs = validate(form);
|
|
||||||
if (Object.keys(errs).length) { setErrors(errs); return; }
|
|
||||||
setSaving(true);
|
|
||||||
setApiError("");
|
|
||||||
const ok = await onSubmit(form, isNew);
|
|
||||||
setSaving(false);
|
|
||||||
if (ok) onClose();
|
|
||||||
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputStyle = (field: keyof ClientAddressFormErrors): React.CSSProperties => ({
|
|
||||||
...S.input,
|
|
||||||
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="addr-modal-title"
|
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 50,
|
|
||||||
background: "rgba(15,23,42,0.55)",
|
|
||||||
backdropFilter: "blur(4px)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 520,
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
borderRadius: "var(--radius-2xl)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "1.25rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
letterSpacing: "0.3em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: "#2563EB",
|
|
||||||
fontWeight: 600,
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isNew ? "إضافة عنوان" : "تعديل عنوان"}
|
|
||||||
</p>
|
|
||||||
<h2
|
|
||||||
id="addr-modal-title"
|
|
||||||
style={{
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
margin: "4px 0 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isNew ? "عنوان جديد" : editAddress?.label}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="إغلاق"
|
|
||||||
style={{
|
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 18,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
noValidate
|
|
||||||
style={{
|
|
||||||
padding: "1.5rem",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{apiError && (
|
|
||||||
<Alert type="error" message={apiError} onClose={() => setApiError("")} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Label + isPrimary */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr auto",
|
|
||||||
gap: "0.75rem",
|
|
||||||
alignItems: "end",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label style={S.label}>
|
|
||||||
نوع العنوان *
|
|
||||||
<select
|
|
||||||
ref={firstRef}
|
|
||||||
style={{ ...inputStyle("label"), cursor: "pointer" }}
|
|
||||||
value={form.label}
|
|
||||||
onChange={set("label")}
|
|
||||||
dir="rtl"
|
|
||||||
>
|
|
||||||
<option value="">اختر النوع</option>
|
|
||||||
{LABEL_PRESETS.map((l) => (
|
|
||||||
<option key={l} value={l}>{l}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors.label && <span style={S.errorText}>{errors.label}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
paddingBottom: 2,
|
|
||||||
cursor: "pointer",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.isPrimary}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((p) => ({ ...p, isPrimary: e.target.checked }))
|
|
||||||
}
|
|
||||||
style={{ width: 14, height: 14, cursor: "pointer" }}
|
|
||||||
/>
|
|
||||||
عنوان رئيسي
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Street */}
|
|
||||||
<label style={S.label}>
|
|
||||||
الشارع / العنوان التفصيلي *
|
|
||||||
<input
|
|
||||||
style={inputStyle("street")}
|
|
||||||
value={form.street}
|
|
||||||
onChange={set("street")}
|
|
||||||
placeholder="شارع الملك فهد، مبنى 12"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
{errors.street && <span style={S.errorText}>{errors.street}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* City + State */}
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={S.label}>
|
|
||||||
المدينة *
|
|
||||||
<input
|
|
||||||
style={inputStyle("city")}
|
|
||||||
value={form.city}
|
|
||||||
onChange={set("city")}
|
|
||||||
placeholder="الرياض"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
{errors.city && <span style={S.errorText}>{errors.city}</span>}
|
|
||||||
</label>
|
|
||||||
<label style={S.label}>
|
|
||||||
المنطقة *
|
|
||||||
<input
|
|
||||||
style={inputStyle("state")}
|
|
||||||
value={form.state}
|
|
||||||
onChange={set("state")}
|
|
||||||
placeholder="منطقة الرياض"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
{errors.state && <span style={S.errorText}>{errors.state}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Postal code + Country */}
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={S.label}>
|
|
||||||
الرمز البريدي *
|
|
||||||
<input
|
|
||||||
style={inputStyle("postalCode")}
|
|
||||||
value={form.postalCode}
|
|
||||||
onChange={set("postalCode")}
|
|
||||||
placeholder="11564"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
{errors.postalCode && (
|
|
||||||
<span style={S.errorText}>{errors.postalCode}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<label style={S.label}>
|
|
||||||
الدولة *
|
|
||||||
<input
|
|
||||||
style={inputStyle("country")}
|
|
||||||
value={form.country}
|
|
||||||
onChange={set("country")}
|
|
||||||
placeholder="المملكة العربية السعودية"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
{errors.country && <span style={S.errorText}>{errors.country}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "0.5rem",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
paddingTop: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={saving}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: "0 1.25rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
cursor: saving ? "not-allowed" : "pointer",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={saving}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: "0 1.5rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "none",
|
|
||||||
background: saving
|
|
||||||
? "var(--color-brand-400)"
|
|
||||||
: "var(--color-brand-600)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#FFF",
|
|
||||||
cursor: saving ? "not-allowed" : "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{saving && <Spinner size="sm" className="text-white" />}
|
|
||||||
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة العنوان" : "حفظ التغييرات"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
"use client";
|
"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 = "رقم هاتف غير صالح (10–15 رقم)";
|
|
||||||
}
|
|
||||||
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`
|
||||||
name: editClient?.name ?? "",
|
const schema = isNew ? createClientSchema : updateClientSchema;
|
||||||
email: editClient?.email ?? "",
|
|
||||||
phone: editClient?.phone ?? "",
|
const {
|
||||||
taxId: editClient?.taxId ?? "",
|
register,
|
||||||
notes: editClient?.notes ?? "",
|
handleSubmit,
|
||||||
});
|
setError,
|
||||||
const [errors, setErrors] = useState<ClientFormErrors>({});
|
formState: { errors, isSubmitting },
|
||||||
const [saving, setSaving] = useState(false);
|
} = useForm<CreateClientFormValues | UpdateClientFormValues>({
|
||||||
const [apiError, setApiError] = useState("");
|
resolver: yupResolver(schema) as never, // FIX 1 applied here
|
||||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
defaultValues: {
|
||||||
|
name: editClient?.name ?? "",
|
||||||
|
email: editClient?.email ?? "",
|
||||||
|
phone: editClient?.phone ?? "",
|
||||||
|
clientType: editClient?.clientType ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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)",
|
||||||
@@ -119,14 +369,14 @@ const thStyle: React.CSSProperties = {
|
|||||||
|
|
||||||
// ── Props ──────────────────────────────────────────────────────────────────
|
// ── Props ──────────────────────────────────────────────────────────────────
|
||||||
interface ClientTableProps {
|
interface ClientTableProps {
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
search: string;
|
search: string;
|
||||||
page: number;
|
page: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
onEdit: (client: Client) => void;
|
onEdit: (client: Client) => void;
|
||||||
onDelete: (client: Client) => void;
|
onDelete: (client: Client) => void;
|
||||||
onAddFirst: () => void;
|
onAddFirst: () => void;
|
||||||
onPageChange: (p: number) => void;
|
onPageChange: (p: number) => void;
|
||||||
/** Navigate to address management for a client */
|
/** Navigate to address management for a client */
|
||||||
onManageAddresses: (client: Client) => void;
|
onManageAddresses: (client: Client) => void;
|
||||||
@@ -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,138 +470,202 @@ 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 (
|
||||||
style={{
|
<li key={c.id}>
|
||||||
display: "grid",
|
{/* ── Main row ── */}
|
||||||
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
|
<div
|
||||||
alignItems: "center",
|
role="button"
|
||||||
gap: "0.5rem",
|
tabIndex={0}
|
||||||
padding: "0.875rem 1.5rem",
|
aria-expanded={isExpanded}
|
||||||
borderBottom: "1px solid var(--color-border)",
|
aria-label={`${c.name} — انقر لعرض التفاصيل`}
|
||||||
background:
|
onKeyDown={(e) => {
|
||||||
i % 2 !== 0
|
if (e.key === "Enter" || e.key === " ") handleRowClick(c);
|
||||||
? "var(--color-surface-muted)"
|
}}
|
||||||
: "transparent",
|
onClick={() => handleRowClick(c)}
|
||||||
fontSize: 13,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => onManageAddresses(c)}
|
|
||||||
title={`إدارة عناوين ${c.name}`}
|
|
||||||
>
|
|
||||||
{/* Name */}
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
display: "grid",
|
||||||
color: "var(--color-text-primary)",
|
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
|
||||||
margin: 0,
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.875rem 1.5rem",
|
||||||
|
borderBottom: isExpanded
|
||||||
|
? "none"
|
||||||
|
: "1px solid var(--color-border)",
|
||||||
|
/* Highlight the expanded row with a brand-tinted background */
|
||||||
|
background: isExpanded
|
||||||
|
? "#EFF6FF"
|
||||||
|
: i % 2 !== 0
|
||||||
|
? "var(--color-surface-muted)"
|
||||||
|
: "transparent",
|
||||||
|
/* Left accent border when expanded (RTL: border-right in visual) */
|
||||||
|
borderRight: isExpanded
|
||||||
|
? "3px solid var(--color-brand-600)"
|
||||||
|
: "3px solid transparent",
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 120ms, border-color 120ms",
|
||||||
|
outline: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{c.name}
|
{/* Name */}
|
||||||
</p>
|
<div>
|
||||||
{c.taxId && (
|
<p
|
||||||
<p
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
{/* Chevron indicator — rotates when expanded */}
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
||||||
|
transition: "transform 200ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</p>
|
||||||
|
{c.taxId && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: 2,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.taxId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<span
|
||||||
style={{
|
style={{
|
||||||
marginTop: 2,
|
|
||||||
fontFamily: "var(--font-mono)",
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.email}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||||
|
{c.phone}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Address count badge — clicking it navigates directly to
|
||||||
|
* /dashboard/clients/[clientId]/addresses (bypasses the detail panel).
|
||||||
|
*/}
|
||||||
|
<AddressBadge
|
||||||
|
count={c.addresses?.length ?? 0}
|
||||||
|
onClick={() => onManageAddresses(c)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Created at */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: "var(--color-text-muted)",
|
color: "var(--color-text-muted)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{c.taxId}
|
{new Date(c.createdAt).toLocaleDateString("ar-SA", {
|
||||||
</p>
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Row action buttons */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<IconBtn
|
||||||
|
title={`تعديل ${c.name}`}
|
||||||
|
color="#1D4ED8"
|
||||||
|
bg="#EFF6FF"
|
||||||
|
borderColor="#BFDBFE"
|
||||||
|
onClick={() => onEdit(c)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</IconBtn>
|
||||||
|
<IconBtn
|
||||||
|
title={`حذف ${c.name}`}
|
||||||
|
color="#DC2626"
|
||||||
|
bg="#FEF2F2"
|
||||||
|
borderColor="#FECACA"
|
||||||
|
onClick={() => onDelete(c)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</IconBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* ── Expandable detail panel ──
|
||||||
|
* Rendered inline below the row when isExpanded is true.
|
||||||
|
* Contains full client metadata + "العناوين" navigation button.
|
||||||
|
*
|
||||||
|
* TEST: Click any row → panel slides in with client details.
|
||||||
|
* Click "العناوين" → router.push to /dashboard/clients/[id]/addresses.
|
||||||
|
* Click same row again → panel collapses.
|
||||||
|
*/}
|
||||||
|
{isExpanded && (
|
||||||
|
<ClientDetailPanel
|
||||||
|
client={c}
|
||||||
|
onGoAddresses={() => onManageAddresses(c)}
|
||||||
|
onEdit={() => onEdit(c)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</li>
|
||||||
|
);
|
||||||
{/* Email */}
|
})}
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-mono)",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#2563EB",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.email}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Phone */}
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
|
||||||
{c.phone}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Address count */}
|
|
||||||
<AddressBadge count={c.addresses?.length ?? 0} />
|
|
||||||
|
|
||||||
{/* Created at */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{new Date(c.createdAt).toLocaleDateString("ar-SA", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()} // prevent row navigation
|
|
||||||
>
|
|
||||||
<IconBtn
|
|
||||||
title={`تعديل ${c.name}`}
|
|
||||||
color="#1D4ED8"
|
|
||||||
bg="#EFF6FF"
|
|
||||||
borderColor="#BFDBFE"
|
|
||||||
onClick={() => onEdit(c)}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
<IconBtn
|
|
||||||
title={`حذف ${c.name}`}
|
|
||||||
color="#DC2626"
|
|
||||||
bg="#FEF2F2"
|
|
||||||
borderColor="#FECACA"
|
|
||||||
onClick={() => onDelete(c)}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" />
|
|
||||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
export { ClientFormModal } from "./Clientformmodal";
|
/**
|
||||||
export { ClientTable } from "./Clienttable";
|
* src/Components/Client/index.ts
|
||||||
export { AddressFormModal } from "./Addressformmodal";
|
*
|
||||||
|
* Barrel exports for all Client-domain components.
|
||||||
|
*
|
||||||
|
* NOTE: Toast is re-exported here for backwards-compatibility, but it now
|
||||||
|
* delegates to src/Components/UI/Toast. Prefer importing Toast directly
|
||||||
|
* from "@/src/Components/UI" in new code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ClientFormModal } from "./Clientformmodal";
|
||||||
|
export { ClientTable } from "./Clienttable";
|
||||||
|
export { AddressFormModal } from "../Client_Adress/Addressformmodal";
|
||||||
export { DeleteConfirmModal } from "./Deleteconfirmmodal";
|
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";
|
||||||
556
src/Components/Client_Adress/Addressformmodal.tsx
Normal file
556
src/Components/Client_Adress/Addressformmodal.tsx
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm, type Resolver } from "react-hook-form";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import { Alert, Spinner } from "../UI";
|
||||||
|
import {
|
||||||
|
createAddressSchema,
|
||||||
|
updateAddressSchema,
|
||||||
|
type CreateAddressFormValues,
|
||||||
|
type UpdateAddressFormValues,
|
||||||
|
} from "@/src/validations/client_address.validator";
|
||||||
|
import type { ClientAddress } from "@/src/types/client";
|
||||||
|
|
||||||
|
// ── Styles ─────────────────────────────────────────────────────────────────
|
||||||
|
const S = {
|
||||||
|
input: {
|
||||||
|
width: "100%",
|
||||||
|
height: 40,
|
||||||
|
padding: "0 0.75rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
} as React.CSSProperties,
|
||||||
|
label: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column" as const,
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
} as React.CSSProperties,
|
||||||
|
errorText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-danger)",
|
||||||
|
fontWeight: 500,
|
||||||
|
} as React.CSSProperties,
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
paddingBottom: "0.25rem",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
} as React.CSSProperties,
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIX 2: Use only the `border` shorthand in both branches — no borderColor/border conflict
|
||||||
|
const withError = (hasError: boolean): React.CSSProperties => ({
|
||||||
|
...S.input,
|
||||||
|
border: hasError
|
||||||
|
? "1px solid var(--color-danger)"
|
||||||
|
: "1px solid var(--color-border)",
|
||||||
|
background: hasError ? "#FEF2F2" : S.input.background,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
interface AddressFormModalProps {
|
||||||
|
editAddress: ClientAddress | null;
|
||||||
|
onClose: () => void;
|
||||||
|
// FIX: removed the "isNew" second argument. The parent (page.tsx) now
|
||||||
|
// figures out create-vs-update itself from addrFormTarget, so it no longer
|
||||||
|
// needs us to tell it isNew. This avoids the two values (isNew here vs
|
||||||
|
// addrFormTarget there) ever disagreeing with each other.
|
||||||
|
onSubmit: (
|
||||||
|
data: CreateAddressFormValues | UpdateAddressFormValues
|
||||||
|
) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressFormModalProps) {
|
||||||
|
const isNew = editAddress === null;
|
||||||
|
|
||||||
|
// NOTE: "schema" variable removed — resolver now picks createAddressSchema
|
||||||
|
// or updateAddressSchema directly inline (see useForm below).
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<CreateAddressFormValues | UpdateAddressFormValues>({
|
||||||
|
// FIX: "as never" was hiding all type errors on resolver/errors.
|
||||||
|
// yupResolver only works with ONE concrete schema type, not a union,
|
||||||
|
// so we still need a cast — but cast to the real Resolver type instead
|
||||||
|
// of "never", so formState.errors keeps proper typing.
|
||||||
|
resolver: (isNew ? yupResolver(createAddressSchema) : yupResolver(updateAddressSchema)) as Resolver<
|
||||||
|
CreateAddressFormValues | UpdateAddressFormValues
|
||||||
|
>,
|
||||||
|
defaultValues: {
|
||||||
|
label: editAddress?.label ?? "",
|
||||||
|
branchName: editAddress?.branchName ?? "",
|
||||||
|
isPrimary: editAddress?.isPrimary ?? false,
|
||||||
|
|
||||||
|
contactPerson: {
|
||||||
|
name: editAddress?.contactPerson?.name ?? "",
|
||||||
|
phone: editAddress?.contactPerson?.phone ?? "",
|
||||||
|
},
|
||||||
|
|
||||||
|
details: {
|
||||||
|
country: editAddress?.details?.country ?? "SA",
|
||||||
|
city: editAddress?.details?.city ?? "",
|
||||||
|
state: editAddress?.details?.state ?? "",
|
||||||
|
district: editAddress?.details?.district ?? "",
|
||||||
|
street: editAddress?.details?.street ?? "",
|
||||||
|
buildingNo: editAddress?.details?.buildingNo ?? "",
|
||||||
|
unitNo: editAddress?.details?.unitNo ?? "",
|
||||||
|
additionalNo: editAddress?.details?.additionalNo ?? "",
|
||||||
|
zipCode: editAddress?.details?.zipCode ?? "",
|
||||||
|
apartment: editAddress?.details?.apartment ?? "",
|
||||||
|
},
|
||||||
|
|
||||||
|
location: {
|
||||||
|
coordinates: editAddress?.location?.coordinates ?? [0, 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailsErr = (errors as never as Record<string, Record<string, { message?: string }>>)?.details ?? {};
|
||||||
|
const contactErr = (errors as never as Record<string, Record<string, { message?: string }>>)?.contactPerson ?? {};
|
||||||
|
const locationErr = (errors as never as Record<string, Record<string, { message?: string }>>)?.location ?? {};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const submitHandler = async (data: CreateAddressFormValues | UpdateAddressFormValues) => {
|
||||||
|
const ok = await onSubmit(data); // FIX: no more isNew arg, see prop type above
|
||||||
|
if (ok) {
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError("label", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="addr-modal-title"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
background: "rgba(15,23,42,0.55)",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 560,
|
||||||
|
maxHeight: "90vh",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRadius: "var(--radius-2xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header — pinned */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "1.25rem 1.5rem",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.3em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isNew ? "إضافة عنوان" : "تعديل عنوان"}
|
||||||
|
</p>
|
||||||
|
<h2
|
||||||
|
id="addr-modal-title"
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: "4px 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isNew ? "عنوان جديد" : editAddress?.label}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="إغلاق"
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 18,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable body */}
|
||||||
|
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(submitHandler)}
|
||||||
|
noValidate
|
||||||
|
style={{
|
||||||
|
padding: "1.5rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{errors.label?.type === "manual" && (
|
||||||
|
<Alert type="error" message={errors.label.message ?? ""} onClose={() => {}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Address Meta */}
|
||||||
|
<p style={S.sectionTitle}>بيانات العنوان</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr auto",
|
||||||
|
gap: "0.75rem",
|
||||||
|
alignItems: "end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* FIX 3: Replaced <select> with a free-text <input> */}
|
||||||
|
<label style={S.label}>
|
||||||
|
نوع العنوان
|
||||||
|
<input
|
||||||
|
{...register("label")}
|
||||||
|
style={withError(!!errors.label && errors.label.type !== "manual")}
|
||||||
|
placeholder="مقترح: منزل / مكتب"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{errors.label && errors.label.type !== "manual" && (
|
||||||
|
<span style={S.errorText}>{errors.label.message}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
paddingBottom: 2,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register("isPrimary")}
|
||||||
|
style={{ width: 14, height: 14, cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
عنوان رئيسي
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={S.label}>
|
||||||
|
اسم الفرع (اختياري)
|
||||||
|
<input
|
||||||
|
{...register("branchName")}
|
||||||
|
style={S.input}
|
||||||
|
placeholder="فرع الرياض"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Address Details */}
|
||||||
|
<p style={S.sectionTitle}>تفاصيل العنوان</p>
|
||||||
|
|
||||||
|
<label style={S.label}>
|
||||||
|
الشارع / العنوان التفصيلي {isNew && "*"}
|
||||||
|
<input
|
||||||
|
{...register("details.street")}
|
||||||
|
style={withError(!!detailsErr.street)}
|
||||||
|
placeholder="شارع الملك فهد، مبنى 12"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{detailsErr.street && (
|
||||||
|
<span style={S.errorText}>{detailsErr.street.message}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={S.label}>
|
||||||
|
المدينة {isNew && "*"}
|
||||||
|
<input
|
||||||
|
{...register("details.city")}
|
||||||
|
style={withError(!!detailsErr.city)}
|
||||||
|
placeholder="الرياض"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{detailsErr.city && (
|
||||||
|
<span style={S.errorText}>{detailsErr.city.message}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={S.label}>
|
||||||
|
المنطقة
|
||||||
|
<input
|
||||||
|
{...register("details.state")}
|
||||||
|
style={withError(!!detailsErr.state)}
|
||||||
|
placeholder="منطقة الرياض"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{detailsErr.state && (
|
||||||
|
<span style={S.errorText}>{detailsErr.state.message}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={S.label}>
|
||||||
|
الحي (اختياري)
|
||||||
|
<input
|
||||||
|
{...register("details.district")}
|
||||||
|
style={S.input}
|
||||||
|
placeholder="حي العليا"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={S.label}>
|
||||||
|
رقم المبنى (اختياري)
|
||||||
|
<input
|
||||||
|
{...register("details.buildingNo")}
|
||||||
|
style={S.input}
|
||||||
|
placeholder="1234"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={S.label}>
|
||||||
|
رقم الوحدة (اختياري)
|
||||||
|
<input
|
||||||
|
{...register("details.unitNo")}
|
||||||
|
style={S.input}
|
||||||
|
placeholder="5678"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={S.label}>
|
||||||
|
الرقم الإضافي (اختياري)
|
||||||
|
<input
|
||||||
|
{...register("details.additionalNo")}
|
||||||
|
style={S.input}
|
||||||
|
placeholder="0000"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={S.label}>
|
||||||
|
الرمز البريدي (اختياري)
|
||||||
|
<input
|
||||||
|
{...register("details.zipCode")}
|
||||||
|
style={withError(!!detailsErr.zipCode)}
|
||||||
|
placeholder="11564"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{detailsErr.zipCode && (
|
||||||
|
<span style={S.errorText}>{detailsErr.zipCode.message}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={S.label}>
|
||||||
|
الدولة
|
||||||
|
<input
|
||||||
|
{...register("details.country")}
|
||||||
|
style={withError(!!detailsErr.country)}
|
||||||
|
placeholder="SA"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{detailsErr.country && (
|
||||||
|
<span style={S.errorText}>{detailsErr.country.message}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={S.label}>
|
||||||
|
الشقة / الطابق (اختياري)
|
||||||
|
<input
|
||||||
|
{...register("details.apartment")}
|
||||||
|
style={S.input}
|
||||||
|
placeholder="الطابق الثالث"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Contact Person */}
|
||||||
|
<p style={S.sectionTitle}>جهة الاتصال (اختياري)</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={S.label}>
|
||||||
|
الاسم
|
||||||
|
<input
|
||||||
|
{...register("contactPerson.name")}
|
||||||
|
style={withError(!!contactErr.name)}
|
||||||
|
placeholder="أحمد محمد"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{contactErr.name && (
|
||||||
|
<span style={S.errorText}>{contactErr.name.message}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={S.label}>
|
||||||
|
رقم الهاتف
|
||||||
|
<input
|
||||||
|
{...register("contactPerson.phone")}
|
||||||
|
style={withError(!!contactErr.phone)}
|
||||||
|
type="tel"
|
||||||
|
placeholder="05xxxxxxxx"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{contactErr.phone && (
|
||||||
|
<span style={S.errorText}>{contactErr.phone.message}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coordinates */}
|
||||||
|
<p style={S.sectionTitle}>الإحداثيات الجغرافية {isNew && "*"}</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={S.label}>
|
||||||
|
خط الطول (Longitude) {isNew && "*"}
|
||||||
|
<input
|
||||||
|
{...register("location.coordinates.0" as never)}
|
||||||
|
style={withError(!!locationErr.coordinates)}
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="46.6753"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={S.label}>
|
||||||
|
خط العرض (Latitude) {isNew && "*"}
|
||||||
|
<input
|
||||||
|
{...register("location.coordinates.1" as never)}
|
||||||
|
style={withError(!!locationErr.coordinates)}
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="24.7136"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{locationErr.coordinates && (
|
||||||
|
<span style={S.errorText}>{locationErr.coordinates.message}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
paddingTop: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.25rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
cursor: isSubmitting ? "not-allowed" : "pointer",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.5rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "none",
|
||||||
|
background: isSubmitting
|
||||||
|
? "var(--color-brand-400)"
|
||||||
|
: "var(--color-brand-600)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: isSubmitting ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting && <Spinner size="sm" className="text-white" />}
|
||||||
|
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة العنوان" : "حفظ التغييرات"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
"use client";
|
"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,
|
||||||
@@ -17,7 +19,7 @@ import type {
|
|||||||
PaymentStatus,
|
PaymentStatus,
|
||||||
} from "@/src/types/order";
|
} from "@/src/types/order";
|
||||||
import type { Client } from "@/src/types/client";
|
import type { Client } from "@/src/types/client";
|
||||||
import type { Trip } from "@/src/types/trip";
|
import type { Trip } from "@/src/types/trip";
|
||||||
import type { OrderSchemaErrors } from "@/src/validations/order.validation";
|
import type { OrderSchemaErrors } from "@/src/validations/order.validation";
|
||||||
|
|
||||||
// ── Shared input / label styles — verbatim from DriverFormModal.tsx ──────────
|
// ── Shared input / label styles — verbatim from DriverFormModal.tsx ──────────
|
||||||
@@ -91,84 +93,119 @@ 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 ─────────────────
|
||||||
const [clients, setClients] = useState<Client[]>([]);
|
const [clients, setClients] = useState<Client[]>([]);
|
||||||
const [trips, setTrips] = useState<Trip[]>([]);
|
const [trips, setTrips] = useState<Trip[]>([]);
|
||||||
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 [clientId, setClientId] = useState(editOrder?.clientId ?? "");
|
const [recipientName, setRecipientName] = useState(
|
||||||
const [tripId, setTripId] = useState(editOrder?.tripId ?? "");
|
editOrder?.recipientName ?? "",
|
||||||
const [type, setType] = useState(editOrder?.type ?? "");
|
);
|
||||||
const [quantity, setQuantity] = useState(String(editOrder?.quantity ?? 1));
|
const [recipientPhone, setRecipientPhone] = useState(
|
||||||
const [weight, setWeight] = useState(
|
editOrder?.recipientPhone ?? "",
|
||||||
|
);
|
||||||
|
const [clientId, setClientId] = useState(editOrder?.clientId ?? "");
|
||||||
|
const [tripId, setTripId] = useState(editOrder?.tripId ?? "");
|
||||||
|
const [type, setType] = useState(editOrder?.type ?? "");
|
||||||
|
const [quantity, setQuantity] = useState(String(editOrder?.quantity ?? 1));
|
||||||
|
const [weight, setWeight] = useState(
|
||||||
editOrder?.weight != null ? String(editOrder.weight) : "",
|
editOrder?.weight != null ? String(editOrder.weight) : "",
|
||||||
);
|
);
|
||||||
const [subTotal, setSubTotal] = useState(
|
const [subTotal, setSubTotal] = useState(
|
||||||
editOrder?.subTotal != null ? String(editOrder.subTotal) : "",
|
editOrder?.subTotal != null ? String(editOrder.subTotal) : "",
|
||||||
);
|
);
|
||||||
const [vatRate, setVatRate] = useState(
|
const [vatRate, setVatRate] = useState(
|
||||||
editOrder?.vatRate != null ? String(editOrder.vatRate) : "",
|
editOrder?.vatRate != null ? String(editOrder.vatRate) : "",
|
||||||
);
|
);
|
||||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod | "">(
|
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod | "">(
|
||||||
editOrder?.paymentMethod ?? "",
|
editOrder?.paymentMethod ?? "",
|
||||||
);
|
);
|
||||||
const [paymentStatus, setPaymentStatus] = useState<PaymentStatus | "">(
|
const [paymentStatus, setPaymentStatus] = useState<PaymentStatus | "">(
|
||||||
editOrder?.paymentStatus ?? "",
|
editOrder?.paymentStatus ?? "",
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 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
|
||||||
? String(editOrder.deliveryAddress!.location!.coordinates![0])
|
? String(editOrder.deliveryAddress!.location!.coordinates![0])
|
||||||
: "",
|
: "",
|
||||||
);
|
);
|
||||||
const [delLat, setDelLat] = useState(
|
const [delLat, setDelLat] = useState(
|
||||||
editOrder?.deliveryAddress?.location?.coordinates?.[1] != null
|
editOrder?.deliveryAddress?.location?.coordinates?.[1] != null
|
||||||
? String(editOrder.deliveryAddress!.location!.coordinates![1])
|
? String(editOrder.deliveryAddress!.location!.coordinates![1])
|
||||||
: "",
|
: "",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pickup address (عنوان الاستلام) — إما ID موجود أو object جديد
|
// Pickup address (عنوان الاستلام) — إما ID موجود أو object جديد
|
||||||
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
|
||||||
? String(editOrder.pickupAddress!.location!.coordinates![0])
|
? String(editOrder.pickupAddress!.location!.coordinates![0])
|
||||||
: "",
|
: "",
|
||||||
);
|
);
|
||||||
const [pkpLat, setPkpLat] = useState(
|
const [pkpLat, setPkpLat] = useState(
|
||||||
editOrder?.pickupAddress?.location?.coordinates?.[1] != null
|
editOrder?.pickupAddress?.location?.coordinates?.[1] != null
|
||||||
? String(editOrder.pickupAddress!.location!.coordinates![1])
|
? String(editOrder.pickupAddress!.location!.coordinates![1])
|
||||||
: "",
|
: "",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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("");
|
||||||
const firstRef = useRef<HTMLInputElement>(null);
|
const firstRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -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 })),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -244,11 +291,11 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
|||||||
recipientName,
|
recipientName,
|
||||||
recipientPhone,
|
recipientPhone,
|
||||||
quantity: quantity ? Number(quantity) : undefined,
|
quantity: quantity ? Number(quantity) : undefined,
|
||||||
weight: weight ? Number(weight) : undefined,
|
weight: weight ? Number(weight) : undefined,
|
||||||
subTotal: subTotal ? Number(subTotal) : undefined,
|
subTotal: subTotal ? Number(subTotal) : undefined,
|
||||||
vatRate: vatRate ? Number(vatRate) : undefined,
|
vatRate: vatRate ? Number(vatRate) : undefined,
|
||||||
paymentMethod: paymentMethod || undefined,
|
paymentMethod: paymentMethod || undefined,
|
||||||
paymentStatus: paymentStatus || undefined,
|
paymentStatus: paymentStatus || undefined,
|
||||||
type: type || undefined,
|
type: type || undefined,
|
||||||
},
|
},
|
||||||
{ abortEarly: false },
|
{ abortEarly: false },
|
||||||
@@ -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();
|
||||||
@@ -289,21 +351,21 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
|||||||
if (tripId) payload.tripId = tripId;
|
if (tripId) payload.tripId = tripId;
|
||||||
|
|
||||||
// Optional fields — include only when filled
|
// Optional fields — include only when filled
|
||||||
if (type) payload.type = type;
|
if (type) payload.type = type;
|
||||||
if (weight) payload.weight = Number(weight);
|
if (weight) payload.weight = Number(weight);
|
||||||
if (subTotal) payload.subTotal = Number(subTotal);
|
if (subTotal) payload.subTotal = Number(subTotal);
|
||||||
if (vatRate) payload.vatRate = Number(vatRate);
|
if (vatRate) payload.vatRate = Number(vatRate);
|
||||||
if (paymentMethod) payload.paymentMethod = paymentMethod;
|
if (paymentMethod) payload.paymentMethod = paymentMethod;
|
||||||
if (paymentStatus) payload.paymentStatus = paymentStatus;
|
if (paymentStatus) payload.paymentStatus = paymentStatus;
|
||||||
|
|
||||||
// ── Build delivery address object (only if at least one field filled) ──
|
// ── Build delivery address object (only if at least one field filled) ──
|
||||||
const delDetails = {
|
const delDetails = {
|
||||||
...(delCity && { city: delCity }),
|
...(delCity && { city: delCity }),
|
||||||
...(delDistrict && { district: delDistrict }),
|
...(delDistrict && { district: delDistrict }),
|
||||||
...(delStreet && { street: delStreet }),
|
...(delStreet && { street: delStreet }),
|
||||||
...(delBuildingNo && { buildingNo: delBuildingNo }),
|
...(delBuildingNo && { buildingNo: delBuildingNo }),
|
||||||
...(delUnitNo && { unitNo: delUnitNo }),
|
...(delUnitNo && { unitNo: delUnitNo }),
|
||||||
...(delZipCode && { zipCode: delZipCode }),
|
...(delZipCode && { zipCode: delZipCode }),
|
||||||
};
|
};
|
||||||
// coordinates are always included when provided (required by backend GeoJSON schema)
|
// coordinates are always included when provided (required by backend GeoJSON schema)
|
||||||
const delCoordinates =
|
const delCoordinates =
|
||||||
@@ -323,12 +385,12 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
|||||||
payload.pickupAddressId = pickupAddressId.trim();
|
payload.pickupAddressId = pickupAddressId.trim();
|
||||||
} else {
|
} else {
|
||||||
const pkpDetails = {
|
const pkpDetails = {
|
||||||
...(pkpCity && { city: pkpCity }),
|
...(pkpCity && { city: pkpCity }),
|
||||||
...(pkpDistrict && { district: pkpDistrict }),
|
...(pkpDistrict && { district: pkpDistrict }),
|
||||||
...(pkpStreet && { street: pkpStreet }),
|
...(pkpStreet && { street: pkpStreet }),
|
||||||
...(pkpBuildingNo && { buildingNo: pkpBuildingNo }),
|
...(pkpBuildingNo && { buildingNo: pkpBuildingNo }),
|
||||||
...(pkpUnitNo && { unitNo: pkpUnitNo }),
|
...(pkpUnitNo && { unitNo: pkpUnitNo }),
|
||||||
...(pkpZipCode && { zipCode: pkpZipCode }),
|
...(pkpZipCode && { zipCode: pkpZipCode }),
|
||||||
};
|
};
|
||||||
const pkpCoordinates =
|
const pkpCoordinates =
|
||||||
pkpLng.trim() && pkpLat.trim()
|
pkpLng.trim() && pkpLat.trim()
|
||||||
@@ -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={{
|
||||||
padding: "1.25rem 1.5rem",
|
display: "flex",
|
||||||
borderBottom: "1px solid var(--color-border)",
|
alignItems: "center",
|
||||||
background: "var(--color-surface-muted)",
|
justifyContent: "space-between",
|
||||||
}}>
|
padding: "1.25rem 1.5rem",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
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)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
isPrimary: data.isPrimary,
|
||||||
city: data.city.trim(),
|
contactPerson: data.contactPerson,
|
||||||
state: data.state.trim(),
|
details: data.details,
|
||||||
postalCode: data.postalCode.trim(),
|
location: data.location,
|
||||||
country: data.country.trim(),
|
|
||||||
isPrimary: data.isPrimary,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
country: 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;
|
||||||
|
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 {
|
||||||
|
|||||||
87
src/validations/client.validator.ts
Normal file
87
src/validations/client.validator.ts
Normal 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();
|
||||||
204
src/validations/client_address.validator.ts
Normal file
204
src/validations/client_address.validator.ts
Normal 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();
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user