Files
pn-new-crm/src/components/clients/client-detail-header.tsx

257 lines
9.5 KiB
TypeScript
Raw Normal View History

'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
feat(post-audit): Phase 5 partial (4/8 templates) + 7.1 editor scaffold + per-entity reminder buttons Phase 5 — luxury-port email tone (4 of 8 templates): - portal-auth.tsx — activation + reset: "It's our pleasure to invite you to the {portName} client portal — your private space to review your berth, manage signed documents, and stay in touch with your sales liaison", sign-off "With warm regards, The {portName} Team", subjects "Welcome to {portName} — activate your client portal" / "Reset your {portName} portal password". - inquiry-client-confirmation.tsx — "We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel", "should anything come to mind in the meantime", sign-off "With warm regards, The {portName} Sales Team". - notification-digest.tsx — "Your {portName} update" header, "Here's what's waiting for you", "With warm regards, The {portName} Team". - document-signing.tsx — all 4 sign-offs ("Dear X, ... Thank you, The {portName} team") rewritten to "With warm regards, The {portName} Team" with capitalised Team for consistency. - Voice captured from old-CRM Nuxt repo (/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/ server/utils/signature-notifications.ts) which already used "Dear", "Best regards", and collective sign-offs. Remaining 4 templates (admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry) + cross-port snapshot tests queued as follow-up. Phase 7.1 — PDF editor scaffold: - New admin route /admin/templates/[id]/editor/page.tsx wired to a client-side <TemplateEditor>. - Renders page 1 via react-pdf (worker URL pattern mirrors components/files/pdf-viewer.tsx); click-to-place markers in percent coordinates so a future page-size swap doesn't shift placements. - Token picker over VALID_MERGE_TOKENS (sorted). - Save persists overlayPositions via PATCH against the existing document_templates row; validator accepts the new field via fieldMapSchema from lib/templates/field-map.ts (no migration needed — overlay_positions JSONB column already exists). - Outer/inner-body split + key-by-templateId remount avoids the in-render setState antipattern when seeding from server data. - Add + delete markers supported. Multi-page, drag, resize, preview, new-PDF upload all defer to 7.2. Per-entity polish: - [+ Reminder] button on yacht / client / interest detail headers, threading defaultYachtId / defaultClientId / defaultInterestId so the ReminderForm opens with the entity pre-linked. - [EOI] badge on yacht detail header when yacht.source === 'eoi-generated' (mirrors the contacts-editor pattern shipped in eaab149). Phase 6 hardening: - imap-bounce-poller strips whitespace from IMAP_PASS so Google Workspace App Passwords (16-char "abcd efgh ijkl mnop" format) work whether pasted with or without spaces. Confirmed via Google docs that the visual spaces are formatting only and must not reach the IMAP server. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:37:19 +02:00
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react';
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { PermissionGate } from '@/components/shared/permission-gate';
feat(client-archive): single-client smart-archive dialog + CSP/middleware fixups UI side of the smart-archive backend that shipped in d07f1ed. - SmartArchiveDialog renders the dossier as a sectioned modal: Pipeline interests, Berths (with next-in-line listed), Yachts, Active reservations, Outstanding invoices, In-flight Documenso envelopes, Auto-handled summary. Each section has a per-row decision dropdown with sensible defaults (release for available/under-offer berths, retain for sold berths and yachts, cancel for active reservations, leave for invoices and documents). - High-stakes deals show an amber warning panel + require a reason in the textarea before the Archive button enables. Signed-document acknowledgment checkbox blocks submission until checked. - Wires into client-detail-header in place of the previous dumb ArchiveConfirmDialog (the simple confirm dialog is kept for the restore case until the smart-restore wizard ships). - Pre-flight blocker banner surfaces dossier.blockers (e.g. active reservation on a sold berth) and disables the Archive button entirely. Two side fixes from CSP rollout: - next.config CSP allows unpkg.com in dev so the react-grab devtool loads. Stripped in prod via the existing isProd flag. - middleware whitelist now passes /manifest.json + icon-*.png + apple-touch-icon through unauthenticated, so PWA installability isn't blocked by the auth redirect. Bulk variant + restore wizard + hard-delete-with-email-code land in follow-on commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:19:34 +02:00
import { SmartArchiveDialog } from '@/components/clients/smart-archive-dialog';
import { SmartRestoreDialog } from '@/components/clients/smart-restore-dialog';
import { HardDeleteDialog } from '@/components/clients/hard-delete-dialog';
feat(post-audit): Phase 5 partial (4/8 templates) + 7.1 editor scaffold + per-entity reminder buttons Phase 5 — luxury-port email tone (4 of 8 templates): - portal-auth.tsx — activation + reset: "It's our pleasure to invite you to the {portName} client portal — your private space to review your berth, manage signed documents, and stay in touch with your sales liaison", sign-off "With warm regards, The {portName} Team", subjects "Welcome to {portName} — activate your client portal" / "Reset your {portName} portal password". - inquiry-client-confirmation.tsx — "We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel", "should anything come to mind in the meantime", sign-off "With warm regards, The {portName} Sales Team". - notification-digest.tsx — "Your {portName} update" header, "Here's what's waiting for you", "With warm regards, The {portName} Team". - document-signing.tsx — all 4 sign-offs ("Dear X, ... Thank you, The {portName} team") rewritten to "With warm regards, The {portName} Team" with capitalised Team for consistency. - Voice captured from old-CRM Nuxt repo (/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/ server/utils/signature-notifications.ts) which already used "Dear", "Best regards", and collective sign-offs. Remaining 4 templates (admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry) + cross-port snapshot tests queued as follow-up. Phase 7.1 — PDF editor scaffold: - New admin route /admin/templates/[id]/editor/page.tsx wired to a client-side <TemplateEditor>. - Renders page 1 via react-pdf (worker URL pattern mirrors components/files/pdf-viewer.tsx); click-to-place markers in percent coordinates so a future page-size swap doesn't shift placements. - Token picker over VALID_MERGE_TOKENS (sorted). - Save persists overlayPositions via PATCH against the existing document_templates row; validator accepts the new field via fieldMapSchema from lib/templates/field-map.ts (no migration needed — overlay_positions JSONB column already exists). - Outer/inner-body split + key-by-templateId remount avoids the in-render setState antipattern when seeding from server data. - Add + delete markers supported. Multi-page, drag, resize, preview, new-PDF upload all defer to 7.2. Per-entity polish: - [+ Reminder] button on yacht / client / interest detail headers, threading defaultYachtId / defaultClientId / defaultInterestId so the ReminderForm opens with the entity pre-linked. - [EOI] badge on yacht detail header when yacht.source === 'eoi-generated' (mirrors the contacts-editor pattern shipped in eaab149). Phase 6 hardening: - imap-bounce-poller strips whitespace from IMAP_PASS so Google Workspace App Passwords (16-char "abcd efgh ijkl mnop" format) work whether pasted with or without spaces. Confirmed via Google docs that the visual spaces are formatting only and must not reach the IMAP server. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:37:19 +02:00
import { ReminderForm } from '@/components/reminders/reminder-form';
import { useQueryClient } from '@tanstack/react-query';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { CountryFlag } from '@/components/shared/country-flag';
feat(portal): replace magic-link with email/password + admin-initiated activation The client portal no longer uses passwordless / magic-link sign-in. Each client now has a `portal_users` row with a scrypt-hashed password, created by an admin from the client detail page; the admin's invite mails an activation link that the client uses to set their own password. Forgot-password is wired through the same token mechanism. Schema (migration `0009_outgoing_rumiko_fujikawa.sql`): - `portal_users` — one per client account, separate from the CRM `users` table (better-auth) so the auth realms stay isolated. Email is globally unique, password is null until activation. - `portal_auth_tokens` — single-use activation / reset tokens. Stores only the SHA-256 hash so a DB compromise never leaks live tokens. Services: - `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps; uses node:crypto), token mint+hash helpers. - `src/lib/services/portal-auth.service.ts` — createPortalUser, resendActivation, activateAccount, signIn (timing-safe), requestPasswordReset, resetPassword. Auth failures throw the new UnauthorizedError (401); enumeration-safe behaviour everywhere. Routes: - POST /api/portal/auth/sign-in — sets the existing portal JWT cookie. - POST /api/portal/auth/forgot-password — always 200. - POST /api/portal/auth/reset-password — token + new password. - POST /api/portal/auth/activate — token + initial password. - POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`). - Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link). UI: - /portal/login — replaced email-only magic-link form with email + password + "forgot password" link. - /portal/forgot-password, /portal/reset-password, /portal/activate — new. - New shared `PasswordSetForm` component used by activate + reset. - New `PortalInviteButton` rendered on the client detail header. Email send: - `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are set (gmail app-password or marina-server creds, configured via env). - `SMTP_FROM` env var lets the sender address be overridden without pinning it to `noreply@${SMTP_HOST}`. Tests: - Smoke spec 17 (client-portal) updated to the new flow: 7/7 green. - Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to match the post-refactor client + invoice forms (drop companyName, use OwnerPicker + billingEmail). - Vitest 652/652 still green; type-check clean. Drops the dead `requestMagicLink` from portal.service.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:02 +02:00
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
feat(gdpr): staff-triggered client-data export bundle (Article 15) Adds a full GDPR Article 15 (right of access) workflow. Staff trigger an export from the client detail; a BullMQ worker assembles every row keyed to that client (profile, contacts, addresses, notes, tags, yachts, company memberships, interests, reservations, invoices, documents, last 500 audit events) into JSON + a self-contained HTML report, ZIPs them, uploads to MinIO, and optionally emails the client a 7-day signed download link. - New table gdpr_exports tracks lifecycle (pending → building → ready → sent / failed) with a 30-day cleanup target - Bundle builder (gdpr-bundle-builder.ts) — pure read-side, tenant- scoped, with HTML escaping to block injection from rogue field values - Worker hook in export queue dispatches on job name 'gdpr-export' - New audit actions: 'request_gdpr_export', 'send_gdpr_export' - API: POST/GET /api/v1/clients/:id/gdpr-export (admin-gated, exports rate-limit, Article-15 audit on POST); GET /:exportId returns a fresh signed URL - UI: <GdprExportButton> dialog on client detail header — admin-only, shows recent exports, supports email-to-client + override recipient, polls every 5s while open - Validation: refuses email-to-client when no primary email + no override (rather than silently dropping the send) Tests: 778/778 vitest (was 771) — +7 covering builder happy path, HTML escaping, tenant isolation, empty client, request-flow validation, and audit / queue interaction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:06:31 +02:00
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
import { cn } from '@/lib/utils';
import { getCountryName } from '@/lib/i18n/countries';
interface ClientDetailHeaderProps {
client: {
id: string;
fullName: string;
nationalityIso?: string | null;
archivedAt?: string | null;
createdAt?: string;
contacts?: Array<{
channel: string;
value: string;
valueE164?: string | null;
isPrimary: boolean;
label?: string | null;
}>;
tags?: Array<{ id: string; name: string; color: string }>;
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
clientPortalEnabled?: boolean;
};
}
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const router = useRouter();
const [archiveOpen, setArchiveOpen] = useState(false);
const [hardDeleteOpen, setHardDeleteOpen] = useState(false);
feat(post-audit): Phase 5 partial (4/8 templates) + 7.1 editor scaffold + per-entity reminder buttons Phase 5 — luxury-port email tone (4 of 8 templates): - portal-auth.tsx — activation + reset: "It's our pleasure to invite you to the {portName} client portal — your private space to review your berth, manage signed documents, and stay in touch with your sales liaison", sign-off "With warm regards, The {portName} Team", subjects "Welcome to {portName} — activate your client portal" / "Reset your {portName} portal password". - inquiry-client-confirmation.tsx — "We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel", "should anything come to mind in the meantime", sign-off "With warm regards, The {portName} Sales Team". - notification-digest.tsx — "Your {portName} update" header, "Here's what's waiting for you", "With warm regards, The {portName} Team". - document-signing.tsx — all 4 sign-offs ("Dear X, ... Thank you, The {portName} team") rewritten to "With warm regards, The {portName} Team" with capitalised Team for consistency. - Voice captured from old-CRM Nuxt repo (/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/ server/utils/signature-notifications.ts) which already used "Dear", "Best regards", and collective sign-offs. Remaining 4 templates (admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry) + cross-port snapshot tests queued as follow-up. Phase 7.1 — PDF editor scaffold: - New admin route /admin/templates/[id]/editor/page.tsx wired to a client-side <TemplateEditor>. - Renders page 1 via react-pdf (worker URL pattern mirrors components/files/pdf-viewer.tsx); click-to-place markers in percent coordinates so a future page-size swap doesn't shift placements. - Token picker over VALID_MERGE_TOKENS (sorted). - Save persists overlayPositions via PATCH against the existing document_templates row; validator accepts the new field via fieldMapSchema from lib/templates/field-map.ts (no migration needed — overlay_positions JSONB column already exists). - Outer/inner-body split + key-by-templateId remount avoids the in-render setState antipattern when seeding from server data. - Add + delete markers supported. Multi-page, drag, resize, preview, new-PDF upload all defer to 7.2. Per-entity polish: - [+ Reminder] button on yacht / client / interest detail headers, threading defaultYachtId / defaultClientId / defaultInterestId so the ReminderForm opens with the entity pre-linked. - [EOI] badge on yacht detail header when yacht.source === 'eoi-generated' (mirrors the contacts-editor pattern shipped in eaab149). Phase 6 hardening: - imap-bounce-poller strips whitespace from IMAP_PASS so Google Workspace App Passwords (16-char "abcd efgh ijkl mnop" format) work whether pasted with or without spaces. Confirmed via Google docs that the visual spaces are formatting only and must not reach the IMAP server. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:37:19 +02:00
const [reminderOpen, setReminderOpen] = useState(false);
const qc = useQueryClient();
const isArchived = !!client.archivedAt;
refactor(clients): drop deprecated yacht/company/proxy columns PR 13: now that all reads are migrated to the dedicated yacht / company / membership entities, drop the columns that mirrored them on `clients`: companyName, isProxy, proxyType, actualOwnerName, relationshipNotes, yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M}, berthSizeDesired. Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to apply. Caller cleanup (zero behavioral change to remaining flows): - Drops the legacy `generateEoi` flow entirely (route, service function, pdfme template, validator schema). The dual-path generate-and-sign service from PR 11 has fully replaced it; the route was no longer wired to the UI. - `clients.service`: company-name search column / WHERE / audit value removed; search now ranks by full name only. - `interests.service`: `resolveLeadCategory` reads dimensions from `yachts` via `interest.yachtId` instead of the dropped `client.yachtLength{Ft,M}`. - `record-export`: client-summary now lists yachts via owner-side lookup (direct + active company memberships); interest-summary fetches yacht via `interest.yachtId`. Both PDF templates updated to read yacht details from the new entity. - `client-detail-header`, `client-picker`, `command-search`, `search-result-item`, `use-search` hook, `types/domain.ts`, `search.service` — drop the companyName badge / sub-label / typed field everywhere it was rendered or fetched. - `ai.ts` worker: drop the company / yacht context lines from the prompt (will be re-added later sourced from the new entities). - `validators/interests.ts`: remove the deprecated public-form flat yacht/company fields. The route already ignores them. - `factories.ts`: drop the `isProxy: false` default. Tests: 652/652 green; type-check clean. The `security-sensitive-data` tests use `companyName` / `isProxy` as arbitrary record keys for a generic util — left unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
const primaryEmail =
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
client.contacts?.find((c) => c.channel === 'email')?.value;
const primaryPhoneContact =
refactor(clients): drop deprecated yacht/company/proxy columns PR 13: now that all reads are migrated to the dedicated yacht / company / membership entities, drop the columns that mirrored them on `clients`: companyName, isProxy, proxyType, actualOwnerName, relationshipNotes, yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M}, berthSizeDesired. Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to apply. Caller cleanup (zero behavioral change to remaining flows): - Drops the legacy `generateEoi` flow entirely (route, service function, pdfme template, validator schema). The dual-path generate-and-sign service from PR 11 has fully replaced it; the route was no longer wired to the UI. - `clients.service`: company-name search column / WHERE / audit value removed; search now ranks by full name only. - `interests.service`: `resolveLeadCategory` reads dimensions from `yachts` via `interest.yachtId` instead of the dropped `client.yachtLength{Ft,M}`. - `record-export`: client-summary now lists yachts via owner-side lookup (direct + active company memberships); interest-summary fetches yacht via `interest.yachtId`. Both PDF templates updated to read yacht details from the new entity. - `client-detail-header`, `client-picker`, `command-search`, `search-result-item`, `use-search` hook, `types/domain.ts`, `search.service` — drop the companyName badge / sub-label / typed field everywhere it was rendered or fetched. - `ai.ts` worker: drop the company / yacht context lines from the prompt (will be re-added later sourced from the new entities). - `validators/interests.ts`: remove the deprecated public-form flat yacht/company fields. The route already ignores them. - `factories.ts`: drop the `isProxy: false` default. Tests: 652/652 green; type-check clean. The `security-sensitive-data` tests use `companyName` / `isProxy` as arbitrary record keys for a generic util — left unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'phone');
const primaryPhone = primaryPhoneContact?.value;
// wa.me requires the E.164 number without the leading "+". Strip from the
// canonical E.164 form when available; otherwise strip non-digits from the
// display value as a best-effort fallback.
const whatsappNumber = primaryPhoneContact?.valueE164
? primaryPhoneContact.valueE164.replace(/^\+/, '')
: primaryPhoneContact?.value
? primaryPhoneContact.value.replace(/[^\d]/g, '')
: null;
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
const addedLabel = client.createdAt
? `Added ${format(new Date(client.createdAt), 'MMM d, yyyy')}`
: null;
return (
<>
<DetailHeaderStrip>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="truncate text-lg font-bold text-foreground sm:text-2xl">
{client.fullName}
</h1>
{isArchived && (
refactor(clients): drop deprecated yacht/company/proxy columns PR 13: now that all reads are migrated to the dedicated yacht / company / membership entities, drop the columns that mirrored them on `clients`: companyName, isProxy, proxyType, actualOwnerName, relationshipNotes, yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M}, berthSizeDesired. Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to apply. Caller cleanup (zero behavioral change to remaining flows): - Drops the legacy `generateEoi` flow entirely (route, service function, pdfme template, validator schema). The dual-path generate-and-sign service from PR 11 has fully replaced it; the route was no longer wired to the UI. - `clients.service`: company-name search column / WHERE / audit value removed; search now ranks by full name only. - `interests.service`: `resolveLeadCategory` reads dimensions from `yachts` via `interest.yachtId` instead of the dropped `client.yachtLength{Ft,M}`. - `record-export`: client-summary now lists yachts via owner-side lookup (direct + active company memberships); interest-summary fetches yacht via `interest.yachtId`. Both PDF templates updated to read yacht details from the new entity. - `client-detail-header`, `client-picker`, `command-search`, `search-result-item`, `use-search` hook, `types/domain.ts`, `search.service` — drop the companyName badge / sub-label / typed field everywhere it was rendered or fetched. - `ai.ts` worker: drop the company / yacht context lines from the prompt (will be re-added later sourced from the new entities). - `validators/interests.ts`: remove the deprecated public-form flat yacht/company fields. The route already ignores them. - `factories.ts`: drop the `isProxy: false` default. Tests: 652/652 green; type-check clean. The `security-sensitive-data` tests use `companyName` / `isProxy` as arbitrary record keys for a generic util — left unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
<Badge variant="secondary" className="text-xs">
Archived
</Badge>
)}
</div>
{country || addedLabel ? (
<p className="flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground sm:text-sm">
{country ? (
<span className="inline-flex items-center gap-1.5">
<CountryFlag
code={client.nationalityIso}
className="h-3 w-4 sm:h-3.5 sm:w-5"
decorative
/>
<span>{country}</span>
</span>
) : null}
{country && addedLabel ? <span aria-hidden>·</span> : null}
{addedLabel ? <span>{addedLabel}</span> : null}
</p>
) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{primaryEmail ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
<Mail />
Email
</a>
</Button>
) : null}
{primaryPhone ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
<Phone />
Call
</a>
</Button>
) : null}
{whatsappNumber ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`https://wa.me/${whatsappNumber}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Message ${primaryPhone} on WhatsApp`}
>
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
<WhatsAppIcon className="h-4 w-4" />
WhatsApp
</a>
</Button>
) : null}
feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width <sm - contacts row restructured so Primary toggle + Remove get their own strip - customize-dashboard footer stacks vertically on mobile; Done full-width - interest-form client/berth pickers no longer cmdk-filter on UUID (typing "Carlos" now returns Carlos Vega instead of "No clients found") Data + consistency - SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces now resolve interest/client source from one place - INTEREST_OUTCOMES adds lost_other (picker, badge, timeline) - Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort - archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles - TableBody last-row uses border-b-0 (not border-0); colored left-accent on the bottom berth row now renders - Hide Invite-to-Portal until port setting === true (was !== false default-show) - OwnerPicker primer query resolves entity name on first paint (no more UUID flash before the popover opens) Terminology - Replaced user-facing "Documenso" with "signing service" / "Generated EOI" / "Manual EOI" in 8 components (admin/internal references kept) - Plainer status-change copy on berth-detail-header Forms + editing - InlineEditableField gained a `date` variant (native picker); applied to company incorporation date and ready for other YYYY-MM-DD plaintext fields - Inline source picker on interest-tabs detail (was free text) - TagPicker self-hides when port has no tags AND nothing is selected - New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom) - Compose dialog follow-up is now a toggle that reveals datetime picker Pipeline milestones - changeStageSchema accepts optional milestoneDate; service stamps it on the matching date column instead of always using now - MilestoneAdvanceButton popover collects a back-date before stage advance - Applied to every "Mark X manually" surface on the interest overview EOI / linked-berths polish - Add-bypass row aligned inline with toggle descriptions - Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their legal vs. public-map consequences Surfaces - Companies list now has the column picker + persisted hidden-column prefs - NotesList aggregate flag enabled on clients, companies, residential_clients (yachts already aggregated) ft/m unit toggle (interim, before drift fix) - "Berth size desired" gets a section-level ft/m toggle; per-field hint shows the converted value. Storage stays canonical-ft for now; the drift-safe persistence migration is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:50:58 +02:00
{!isArchived && client.clientPortalEnabled === true ? (
<div className="hidden sm:inline-flex">
<PortalInviteButton
clientId={client.id}
clientName={client.fullName}
defaultEmail={primaryEmail}
/>
</div>
) : null}
<div className="hidden sm:inline-flex">
<GdprExportButton clientId={client.id} />
</div>
</div>
{client.tags && client.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{client.tags.map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
</div>
)}
</div>
{/* Top-right: archive/restore + (for archived clients with the
right perm) permanently-delete. Destructive actions sit out
of the primary action flow. */}
<div className="flex items-start gap-1">
{isArchived && (
<PermissionGate resource="admin" action="permanently_delete_clients">
<button
type="button"
onClick={() => setHardDeleteOpen(true)}
aria-label="Permanently delete client"
title="Permanently delete client"
className="shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="size-4" aria-hidden />
</button>
</PermissionGate>
feat(portal): replace magic-link with email/password + admin-initiated activation The client portal no longer uses passwordless / magic-link sign-in. Each client now has a `portal_users` row with a scrypt-hashed password, created by an admin from the client detail page; the admin's invite mails an activation link that the client uses to set their own password. Forgot-password is wired through the same token mechanism. Schema (migration `0009_outgoing_rumiko_fujikawa.sql`): - `portal_users` — one per client account, separate from the CRM `users` table (better-auth) so the auth realms stay isolated. Email is globally unique, password is null until activation. - `portal_auth_tokens` — single-use activation / reset tokens. Stores only the SHA-256 hash so a DB compromise never leaks live tokens. Services: - `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps; uses node:crypto), token mint+hash helpers. - `src/lib/services/portal-auth.service.ts` — createPortalUser, resendActivation, activateAccount, signIn (timing-safe), requestPasswordReset, resetPassword. Auth failures throw the new UnauthorizedError (401); enumeration-safe behaviour everywhere. Routes: - POST /api/portal/auth/sign-in — sets the existing portal JWT cookie. - POST /api/portal/auth/forgot-password — always 200. - POST /api/portal/auth/reset-password — token + new password. - POST /api/portal/auth/activate — token + initial password. - POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`). - Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link). UI: - /portal/login — replaced email-only magic-link form with email + password + "forgot password" link. - /portal/forgot-password, /portal/reset-password, /portal/activate — new. - New shared `PasswordSetForm` component used by activate + reset. - New `PortalInviteButton` rendered on the client detail header. Email send: - `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are set (gmail app-password or marina-server creds, configured via env). - `SMTP_FROM` env var lets the sender address be overridden without pinning it to `noreply@${SMTP_HOST}`. Tests: - Smoke spec 17 (client-portal) updated to the new flow: 7/7 green. - Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to match the post-refactor client + invoice forms (drop companyName, use OwnerPicker + billingEmail). - Vitest 652/652 still green; type-check clean. Drops the dead `requestMagicLink` from portal.service.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:02 +02:00
)}
feat(post-audit): Phase 5 partial (4/8 templates) + 7.1 editor scaffold + per-entity reminder buttons Phase 5 — luxury-port email tone (4 of 8 templates): - portal-auth.tsx — activation + reset: "It's our pleasure to invite you to the {portName} client portal — your private space to review your berth, manage signed documents, and stay in touch with your sales liaison", sign-off "With warm regards, The {portName} Team", subjects "Welcome to {portName} — activate your client portal" / "Reset your {portName} portal password". - inquiry-client-confirmation.tsx — "We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel", "should anything come to mind in the meantime", sign-off "With warm regards, The {portName} Sales Team". - notification-digest.tsx — "Your {portName} update" header, "Here's what's waiting for you", "With warm regards, The {portName} Team". - document-signing.tsx — all 4 sign-offs ("Dear X, ... Thank you, The {portName} team") rewritten to "With warm regards, The {portName} Team" with capitalised Team for consistency. - Voice captured from old-CRM Nuxt repo (/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/ server/utils/signature-notifications.ts) which already used "Dear", "Best regards", and collective sign-offs. Remaining 4 templates (admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry) + cross-port snapshot tests queued as follow-up. Phase 7.1 — PDF editor scaffold: - New admin route /admin/templates/[id]/editor/page.tsx wired to a client-side <TemplateEditor>. - Renders page 1 via react-pdf (worker URL pattern mirrors components/files/pdf-viewer.tsx); click-to-place markers in percent coordinates so a future page-size swap doesn't shift placements. - Token picker over VALID_MERGE_TOKENS (sorted). - Save persists overlayPositions via PATCH against the existing document_templates row; validator accepts the new field via fieldMapSchema from lib/templates/field-map.ts (no migration needed — overlay_positions JSONB column already exists). - Outer/inner-body split + key-by-templateId remount avoids the in-render setState antipattern when seeding from server data. - Add + delete markers supported. Multi-page, drag, resize, preview, new-PDF upload all defer to 7.2. Per-entity polish: - [+ Reminder] button on yacht / client / interest detail headers, threading defaultYachtId / defaultClientId / defaultInterestId so the ReminderForm opens with the entity pre-linked. - [EOI] badge on yacht detail header when yacht.source === 'eoi-generated' (mirrors the contacts-editor pattern shipped in eaab149). Phase 6 hardening: - imap-bounce-poller strips whitespace from IMAP_PASS so Google Workspace App Passwords (16-char "abcd efgh ijkl mnop" format) work whether pasted with or without spaces. Confirmed via Google docs that the visual spaces are formatting only and must not reach the IMAP server. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:37:19 +02:00
<button
type="button"
onClick={() => setReminderOpen(true)}
aria-label="Add reminder for this client"
title="Add reminder for this client"
className="shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-primary"
>
<Bell className="size-4" aria-hidden />
</button>
<button
type="button"
onClick={() => setArchiveOpen(true)}
aria-label={isArchived ? 'Restore client' : 'Archive client'}
title={isArchived ? 'Restore client' : 'Archive client'}
className={cn(
'shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors',
'hover:bg-foreground/5',
isArchived ? 'hover:text-emerald-600' : 'hover:text-destructive',
)}
>
{isArchived ? (
<RotateCcw className="size-4" aria-hidden />
) : (
<Archive className="size-4" aria-hidden />
)}
</button>
</div>
</div>
</DetailHeaderStrip>
feat(post-audit): Phase 5 partial (4/8 templates) + 7.1 editor scaffold + per-entity reminder buttons Phase 5 — luxury-port email tone (4 of 8 templates): - portal-auth.tsx — activation + reset: "It's our pleasure to invite you to the {portName} client portal — your private space to review your berth, manage signed documents, and stay in touch with your sales liaison", sign-off "With warm regards, The {portName} Team", subjects "Welcome to {portName} — activate your client portal" / "Reset your {portName} portal password". - inquiry-client-confirmation.tsx — "We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel", "should anything come to mind in the meantime", sign-off "With warm regards, The {portName} Sales Team". - notification-digest.tsx — "Your {portName} update" header, "Here's what's waiting for you", "With warm regards, The {portName} Team". - document-signing.tsx — all 4 sign-offs ("Dear X, ... Thank you, The {portName} team") rewritten to "With warm regards, The {portName} Team" with capitalised Team for consistency. - Voice captured from old-CRM Nuxt repo (/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/ server/utils/signature-notifications.ts) which already used "Dear", "Best regards", and collective sign-offs. Remaining 4 templates (admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry) + cross-port snapshot tests queued as follow-up. Phase 7.1 — PDF editor scaffold: - New admin route /admin/templates/[id]/editor/page.tsx wired to a client-side <TemplateEditor>. - Renders page 1 via react-pdf (worker URL pattern mirrors components/files/pdf-viewer.tsx); click-to-place markers in percent coordinates so a future page-size swap doesn't shift placements. - Token picker over VALID_MERGE_TOKENS (sorted). - Save persists overlayPositions via PATCH against the existing document_templates row; validator accepts the new field via fieldMapSchema from lib/templates/field-map.ts (no migration needed — overlay_positions JSONB column already exists). - Outer/inner-body split + key-by-templateId remount avoids the in-render setState antipattern when seeding from server data. - Add + delete markers supported. Multi-page, drag, resize, preview, new-PDF upload all defer to 7.2. Per-entity polish: - [+ Reminder] button on yacht / client / interest detail headers, threading defaultYachtId / defaultClientId / defaultInterestId so the ReminderForm opens with the entity pre-linked. - [EOI] badge on yacht detail header when yacht.source === 'eoi-generated' (mirrors the contacts-editor pattern shipped in eaab149). Phase 6 hardening: - imap-bounce-poller strips whitespace from IMAP_PASS so Google Workspace App Passwords (16-char "abcd efgh ijkl mnop" format) work whether pasted with or without spaces. Confirmed via Google docs that the visual spaces are formatting only and must not reach the IMAP server. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:37:19 +02:00
<ReminderForm
open={reminderOpen}
onOpenChange={setReminderOpen}
defaultClientId={client.id}
onSuccess={() => qc.invalidateQueries({ queryKey: ['reminders'] })}
/>
feat(client-archive): single-client smart-archive dialog + CSP/middleware fixups UI side of the smart-archive backend that shipped in d07f1ed. - SmartArchiveDialog renders the dossier as a sectioned modal: Pipeline interests, Berths (with next-in-line listed), Yachts, Active reservations, Outstanding invoices, In-flight Documenso envelopes, Auto-handled summary. Each section has a per-row decision dropdown with sensible defaults (release for available/under-offer berths, retain for sold berths and yachts, cancel for active reservations, leave for invoices and documents). - High-stakes deals show an amber warning panel + require a reason in the textarea before the Archive button enables. Signed-document acknowledgment checkbox blocks submission until checked. - Wires into client-detail-header in place of the previous dumb ArchiveConfirmDialog (the simple confirm dialog is kept for the restore case until the smart-restore wizard ships). - Pre-flight blocker banner surfaces dossier.blockers (e.g. active reservation on a sold berth) and disables the Archive button entirely. Two side fixes from CSP rollout: - next.config CSP allows unpkg.com in dev so the react-grab devtool loads. Stripped in prod via the existing isProd flag. - middleware whitelist now passes /manifest.json + icon-*.png + apple-touch-icon through unauthenticated, so PWA installability isn't blocked by the auth redirect. Bulk variant + restore wizard + hard-delete-with-email-code land in follow-on commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:19:34 +02:00
{isArchived ? (
<SmartRestoreDialog
feat(client-archive): single-client smart-archive dialog + CSP/middleware fixups UI side of the smart-archive backend that shipped in d07f1ed. - SmartArchiveDialog renders the dossier as a sectioned modal: Pipeline interests, Berths (with next-in-line listed), Yachts, Active reservations, Outstanding invoices, In-flight Documenso envelopes, Auto-handled summary. Each section has a per-row decision dropdown with sensible defaults (release for available/under-offer berths, retain for sold berths and yachts, cancel for active reservations, leave for invoices and documents). - High-stakes deals show an amber warning panel + require a reason in the textarea before the Archive button enables. Signed-document acknowledgment checkbox blocks submission until checked. - Wires into client-detail-header in place of the previous dumb ArchiveConfirmDialog (the simple confirm dialog is kept for the restore case until the smart-restore wizard ships). - Pre-flight blocker banner surfaces dossier.blockers (e.g. active reservation on a sold berth) and disables the Archive button entirely. Two side fixes from CSP rollout: - next.config CSP allows unpkg.com in dev so the react-grab devtool loads. Stripped in prod via the existing isProd flag. - middleware whitelist now passes /manifest.json + icon-*.png + apple-touch-icon through unauthenticated, so PWA installability isn't blocked by the auth redirect. Bulk variant + restore wizard + hard-delete-with-email-code land in follow-on commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:19:34 +02:00
open={archiveOpen}
onOpenChange={setArchiveOpen}
clientId={client.id}
clientName={client.fullName}
feat(client-archive): single-client smart-archive dialog + CSP/middleware fixups UI side of the smart-archive backend that shipped in d07f1ed. - SmartArchiveDialog renders the dossier as a sectioned modal: Pipeline interests, Berths (with next-in-line listed), Yachts, Active reservations, Outstanding invoices, In-flight Documenso envelopes, Auto-handled summary. Each section has a per-row decision dropdown with sensible defaults (release for available/under-offer berths, retain for sold berths and yachts, cancel for active reservations, leave for invoices and documents). - High-stakes deals show an amber warning panel + require a reason in the textarea before the Archive button enables. Signed-document acknowledgment checkbox blocks submission until checked. - Wires into client-detail-header in place of the previous dumb ArchiveConfirmDialog (the simple confirm dialog is kept for the restore case until the smart-restore wizard ships). - Pre-flight blocker banner surfaces dossier.blockers (e.g. active reservation on a sold berth) and disables the Archive button entirely. Two side fixes from CSP rollout: - next.config CSP allows unpkg.com in dev so the react-grab devtool loads. Stripped in prod via the existing isProd flag. - middleware whitelist now passes /manifest.json + icon-*.png + apple-touch-icon through unauthenticated, so PWA installability isn't blocked by the auth redirect. Bulk variant + restore wizard + hard-delete-with-email-code land in follow-on commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:19:34 +02:00
/>
) : (
<SmartArchiveDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}
clientId={client.id}
clientName={client.fullName}
/>
)}
{isArchived && (
<HardDeleteDialog
open={hardDeleteOpen}
onOpenChange={setHardDeleteOpen}
clientId={client.id}
clientName={client.fullName}
onDeleted={() => router.back()}
/>
)}
</>
);
}