diff --git a/app/dashboard/clients/[clientId]/addresses/page.tsx b/app/dashboard/clients/[clientId]/addresses/page.tsx index 7fcb6ad..7680948 100644 --- a/app/dashboard/clients/[clientId]/addresses/page.tsx +++ b/app/dashboard/clients/[clientId]/addresses/page.tsx @@ -1,23 +1,36 @@ "use client"; + + import { useEffect, useState } from "react"; 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 { clientService } from "@/src/services/client.service"; import { getStoredToken } from "@/src/lib/auth"; + +// ── Types ────────────────────────────────────────────────────────────────── import type { Client, ClientAddress, ClientAddressFormData, + ClientFormData, } 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 { const map: Record = { "فوترة": "💳", @@ -34,16 +47,29 @@ function labelIcon(label: string): string { 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 { address: ClientAddress; onEdit: () => void; onDelete: () => 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 (
{/* Label row */} -
+
- + {address.label}
+ + {/* Primary badge */} {address.isPrimary && ( - رئيسي + ★ رئيسي )}
@@ -101,7 +144,7 @@ function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardPr >

{address.street}

- {address.city}، {address.state} {address.postalCode} + {address.city}،{" "}{address.state} {address.postalCode}

{address.country}

@@ -117,18 +160,28 @@ function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardPr borderTop: "1px solid var(--color-border)", }} > - + تعديل + {/* + * Set-primary button — only for non-primary addresses. + * Shows a loading indicator while the API call is in-flight. + */} {!address.isPrimary && ( - تعيين كرئيسي + {settingPrimary ? "…" : "تعيين كرئيسي"} )} @@ -152,6 +205,7 @@ function ActionBtn({ bg, border, style, + disabled, children, }: { onClick: () => void; @@ -159,12 +213,14 @@ function ActionBtn({ bg: string; border: string; style?: React.CSSProperties; + disabled?: boolean; children: React.ReactNode; }) { return ( + )} + + ))} + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// ClientEditModalWithAddresses — wraps ClientFormModal + AddressMiniList +// ───────────────────────────────────────────────────────────────────────────── + +/** + * ClientEditModalWithAddresses + */ +interface ClientEditModalWithAddressesProps { + client: Client; + addresses: ClientAddress[]; + onClose: () => void; + onSubmit: (data: ClientFormData, isNew: boolean) => Promise; + onMakePrimary: (id: string) => Promise; +} + +function ClientEditModalWithAddresses({ + client, + addresses, + onClose, + onSubmit, + onMakePrimary, +}: ClientEditModalWithAddressesProps) { + const [settingId, setSettingId] = useState(null); + + const handleSetPrimary = async (id: string) => { + setSettingId(id); + await onMakePrimary(id); + setSettingId(null); + }; + + return ( + // We reuse the backdrop and center the compound modal manually +
{ 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 */} +
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 */} +
+ {/* We mount ClientFormModal as a standalone dialog — it handles its own + form logic. We intercept onClose to close the whole compound modal. */} + +
+ + {/* + * Address section — appended below the form card. + * Satisfies: "create a section that displays all available addresses." + */} +
+ {/* Section header */} +
+
+

+ عناوين العميل +

+

+ {addresses.length} عنوان — اضغط "تعيين كرئيسي" لتغيير العنوان الرئيسي +

+
+
+ + {/* Address mini-list */} +
+ +
+
+
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Page component +// ───────────────────────────────────────────────────────────────────────────── export default function ClientAddressesPage() { - const params = useParams(); 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 ─────────────────────────────────────────────────── - const [client, setClient] = useState(null); + const [client, setClient] = useState(null); const [clientLoading, setClientLoading] = useState(true); + // Fetch the parent client so we can show their name / details in the header useEffect(() => { if (!clientId) return; setClientLoading(true); clientService .getById(clientId, getStoredToken()) .then((res) => setClient(res.data)) - .catch(() => router.replace("/clients")) + .catch(() => router.replace("/dashboard/clients")) .finally(() => setClientLoading(false)); }, [clientId, router]); @@ -211,29 +547,55 @@ export default function ClientAddressesPage() { addresses, loading: addrLoading, error, - notification, + notification, // { type, message } — matches ToastNotification clearError, createAddress, updateAddress, deleteAddress, makePrimary, + reload, } = 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) ────────────────────────────── - // false = closed | null = create mode | ClientAddress = edit mode - const [formTarget, setFormTarget] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); - const [deleting, setDeleting] = useState(false); + // ── Modal state ────────────────────────────────────────────────────────── + // Address form: false = closed | null = create | ClientAddress = edit + const [addrFormTarget, setAddrFormTarget] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); - // ── Handlers ───────────────────────────────────────────────────────────── - const handleFormSubmit = async ( - data: ClientAddressFormData, - isNew: boolean + // Client edit modal (with address section) + const [editingClient, setEditingClient] = useState(false); + + // Per-card primary-setting loading state (card id) + const [settingPrimaryId, setSettingPrimaryId] = useState(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 => { - if (isNew) return createAddress(data); - return updateAddress((formTarget as ClientAddress).id, data); + if (addrFormTarget === null) { + // 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 () => { if (!deleteTarget || deleting) return; setDeleting(true); @@ -242,7 +604,35 @@ export default function ClientAddressesPage() { 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 => { + setSettingPrimaryId(id); + const ok = await makePrimary(id); + setSettingPrimaryId(null); + return ok; + }; + + // ── Client edit submit ─────────────────────────────────────────────────── + const handleClientEditSubmit = async ( + data: ClientFormData, + _isNew: boolean, + ): Promise => { + 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) { 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 }. + */} - {/* Create / Edit address modal */} - {formTarget !== false && ( + {/* ── Address create / edit modal ── */} + {addrFormTarget !== false && ( setFormTarget(false)} - onSubmit={handleFormSubmit} + editAddress={addrFormTarget} + onClose={() => setAddrFormTarget(false)} + 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 && ( )} + {/* + * ── Client edit modal with embedded address section ── + * TEST: Click "تعديل بيانات العميل" in the header. + * → Modal shows client form + address list with "تعيين كرئيسي" buttons. + */} + {editingClient && client && ( + setEditingClient(false)} + onSubmit={handleClientEditSubmit} + onMakePrimary={makePrimaryForCard} + /> + )} +
{/* ── Page header ── */} @@ -317,7 +729,7 @@ export default function ClientAddressesPage() { {/* Back link */} + {/* Header action buttons */} +
+ {/* Edit client (opens compound modal with address section) */} + {client && ( + + )} + + {/* Add address button */} + +
{/* Client info strip */} @@ -421,7 +887,12 @@ export default function ClientAddressesPage() { borderTop: "1px solid var(--color-border)", }} > - + {client.name} - {/* General load error */} - {error && } + {/* + * 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 && ( + + )} - {/* Address grid */} + {/* ── Address grid ── */} {addrLoading ? ( + /* Loading skeleton-ish state */
- جارٍ التحميل… +
+ جارٍ تحميل العناوين…
) : addresses.length === 0 ? ( + /* Empty state */
) : ( + /* + * 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. + */
setFormTarget(addr)} + onEdit={() => setAddrFormTarget(addr)} onDelete={() => setDeleteTarget(addr)} - onMakePrimary={() => makePrimary(addr.id)} + onMakePrimary={() => makePrimaryForCard(addr.id)} + settingPrimary={settingPrimaryId === addr.id} /> ))}
diff --git a/app/dashboard/clients/page.tsx b/app/dashboard/clients/page.tsx index 9f4c375..f2f1ac4 100644 --- a/app/dashboard/clients/page.tsx +++ b/app/dashboard/clients/page.tsx @@ -1,13 +1,36 @@ "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 { useRouter } from "next/navigation"; -import { Alert } from "@/src/Components/UI"; -import { Toast } from "@/src/Components/Client/Toast"; -import { useClients } from "@/src/hooks/useClients"; + +// ── UI components ────────────────────────────────────────────────────────── +// NOTE: Toast is now imported from the canonical UI barrel, NOT from Client/Toast. +import { Alert, Toast } from "@/src/Components/UI"; + +// ── Client-specific components ───────────────────────────────────────────── +import { useClients } from "@/src/hooks/useClients"; import type { Client, ClientFormData } from "@/src/types/client"; -import { ClientFormModal } from "@/src/Components/Client/Clientformmodal"; -import { ClientTable } from "@/src/Components/Client/Clienttable"; +import { ClientFormModal } from "@/src/Components/Client/Clientformmodal"; +import { ClientTable } from "@/src/Components/Client/Clienttable"; import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal"; export default function ClientsPage() { @@ -31,7 +54,7 @@ export default function ClientsPage() { // ── Create / Update handler ────────────────────────────────────────────── const handleFormSubmit = async ( data: ClientFormData, - isNew: boolean + isNew: boolean, ): Promise => { if (isNew) return createClient(data); return updateClient((formTarget as Client).id, data); @@ -47,6 +70,8 @@ export default function ClientsPage() { }; // ── Navigate to address management ────────────────────────────────────── + // This is called both by the AddressBadge click and the "العناوين" button + // inside the ClientDetailPanel. const handleManageAddresses = (client: Client) => { router.push(`/dashboard/clients/${client.id}/addresses`); }; @@ -54,7 +79,11 @@ export default function ClientsPage() { // ── Render ─────────────────────────────────────────────────────────────── 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. + */} {/* Create / Edit modal */} diff --git a/package-lock.json b/package-lock.json index 1defd2c..c623096 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,12 @@ "name": "logistic_client", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^5.4.0", "@tabler/icons-webfont": "^3.44.0", "next": "16.2.7", "react": "19.2.4", "react-dom": "19.2.4", + "react-hook-form": "^7.80.0", "yup": "^1.7.1" }, "devDependencies": { @@ -457,6 +459,18 @@ "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": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1330,6 +1344,12 @@ "dev": true, "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": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -7097,6 +7117,22 @@ "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": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index fee9919..3e5de9d 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,12 @@ "lint": "eslint" }, "dependencies": { + "@hookform/resolvers": "^5.4.0", "@tabler/icons-webfont": "^3.44.0", "next": "16.2.7", "react": "19.2.4", "react-dom": "19.2.4", + "react-hook-form": "^7.80.0", "yup": "^1.7.1" }, "devDependencies": { diff --git a/src/Components/Client/Addressformmodal.tsx b/src/Components/Client/Addressformmodal.tsx deleted file mode 100644 index 44f0942..0000000 --- a/src/Components/Client/Addressformmodal.tsx +++ /dev/null @@ -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; -} - -// ── Component ────────────────────────────────────────────────────────────── -export function AddressFormModal({ - editAddress, - onClose, - onSubmit, -}: AddressFormModalProps) { - const isNew = editAddress === null; - - const [form, setForm] = useState({ - 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({}); - const [saving, setSaving] = useState(false); - const [apiError, setApiError] = useState(""); - const firstRef = useRef(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) => { - 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 ( -
{ 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", - }} - > -
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 */} -
-
-

- {isNew ? "إضافة عنوان" : "تعديل عنوان"} -

-

- {isNew ? "عنوان جديد" : editAddress?.label} -

-
- -
- - {/* Body */} -
- {apiError && ( - setApiError("")} /> - )} - - {/* Label + isPrimary */} -
- - - -
- - {/* Street */} - - - {/* City + State */} -
- - -
- - {/* Postal code + Country */} -
- - -
- - {/* Actions */} -
- - -
- -
-
- ); -} \ No newline at end of file diff --git a/src/Components/Client/Clientformmodal.tsx b/src/Components/Client/Clientformmodal.tsx index de6c757..bb60ede 100644 --- a/src/Components/Client/Clientformmodal.tsx +++ b/src/Components/Client/Clientformmodal.tsx @@ -1,10 +1,19 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; import { Alert, Spinner } from "../UI"; -import type { Client, ClientFormData, ClientFormErrors } from "@/src/types/client"; +import { + createClientSchema, + updateClientSchema, + CLIENT_TYPES, + type CreateClientFormValues, + type UpdateClientFormValues, +} from "@/src/validations/client.validator"; +import type { Client } from "@/src/types/client"; -// ── Fixed styles (same token system as UserFormModal) ────────────────────── +// ── Styles ───────────────────────────────────────────────────────────────── const S = { input: { width: "100%", @@ -33,83 +42,58 @@ const S = { } as React.CSSProperties, }; -// ── Validation ───────────────────────────────────────────────────────────── -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const PHONE_RE = /^\+?[0-9]{10,15}$/; - -function validate(data: ClientFormData): ClientFormErrors { - const e: ClientFormErrors = {}; - if (!data.name.trim()) e.name = "اسم العميل مطلوب"; - if (!data.email.trim()) { - e.email = "البريد الإلكتروني مطلوب"; - } else if (!EMAIL_RE.test(data.email)) { - e.email = "صيغة البريد الإلكتروني غير صحيحة"; - } - if (!data.phone.trim()) { - e.phone = "رقم الهاتف مطلوب"; - } else if (!PHONE_RE.test(data.phone.replace(/\s/g, ""))) { - e.phone = "رقم هاتف غير صالح (10–15 رقم)"; - } - return e; -} +const withError = (hasError: boolean): React.CSSProperties => ({ + ...S.input, + ...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}), +}); // ── Props ────────────────────────────────────────────────────────────────── interface ClientFormModalProps { - editClient: Client | null; // null = create mode, Client = edit mode + editClient: Client | null; onClose: () => void; - onSubmit: (data: ClientFormData, isNew: boolean) => Promise; + onSubmit: ( + data: CreateClientFormValues | UpdateClientFormValues, + isNew: boolean + ) => Promise; } // ── Component ────────────────────────────────────────────────────────────── -export function ClientFormModal({ - editClient, - onClose, - onSubmit, -}: ClientFormModalProps) { +export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormModalProps) { const isNew = editClient === null; - const [form, setForm] = useState({ - name: editClient?.name ?? "", - email: editClient?.email ?? "", - phone: editClient?.phone ?? "", - taxId: editClient?.taxId ?? "", - notes: editClient?.notes ?? "", - }); - const [errors, setErrors] = useState({}); - const [saving, setSaving] = useState(false); - const [apiError, setApiError] = useState(""); - const firstInputRef = useRef(null); + // FIX 1: was `Schema` (capital S) — now correctly `schema` + const schema = isNew ? createClientSchema : updateClientSchema; + + const { + register, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: yupResolver(schema) as never, // FIX 1 applied here + defaultValues: { + name: editClient?.name ?? "", + email: editClient?.email ?? "", + phone: editClient?.phone ?? "", + clientType: editClient?.clientType ?? undefined, + }, + }); - useEffect(() => { firstInputRef.current?.focus(); }, []); useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [onClose]); - const set = (field: keyof ClientFormData) => - (e: React.ChangeEvent) => { - setForm((p) => ({ ...p, [field]: e.target.value })); - if (errors[field]) setErrors((p) => ({ ...p, [field]: undefined })); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const errs = validate(form); - if (Object.keys(errs).length) { setErrors(errs); return; } - setSaving(true); - setApiError(""); - const ok = await onSubmit(form, isNew); - setSaving(false); - if (ok) onClose(); - else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."); + const submitHandler = async (data: CreateClientFormValues | UpdateClientFormValues) => { + const ok = await onSubmit(data, isNew); + if (ok) { + onClose(); + } else { + setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." }); + } }; - const inputStyle = (field: keyof ClientFormErrors): React.CSSProperties => ({ - ...S.input, - ...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}), - }); - return (
- {apiError && ( - setApiError("")} /> + {errors.name?.type === "manual" && ( + {}} /> )} - {/* Name */} - {/* Email + Phone */}
+
- {/* Tax ID */} - - {/* Notes */} -