fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md

Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to
Sheet side=right so every detail-preview surface uses the same
primitive. Document the doctrine: Sheet for side panels on both desktop
and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX
(currently just MoreSheet).

Closes ui/ux M11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 11:50:07 +02:00
parent b2588ecdd8
commit 4233aa3ac3
94 changed files with 1674 additions and 895 deletions

View File

@@ -93,6 +93,7 @@ src/
- **Documenso v1 vs v2 endpoint routing:** `getPortDocumensoConfig(portId)` resolves the per-port `apiVersion` ('v1' | 'v2'). `documenso-client.ts` exports version-aware wrappers: `getDocument`, `createDocument`, `sendDocument`, `sendReminder`, `downloadSignedPdf`, `voidDocument`, `placeFields`. v2 → `/api/v2/envelope/*` (`create` is multipart with `{payload, files}`; `distribute` returns per-recipient `signingUrl` in one round-trip; `redistribute` for reminders; `field/create-many` for bulk placement with percent coords + `fieldMeta`). v1 → existing `/api/v1/documents/*` paths. **Template flow is intentionally still v1** (`/api/v1/templates/{id}/generate-document` with `formValues` keyed by name) — v2 instances accept it via backward compat. Full v2 `/template/use` migration with `prefillFields` by ID needs per-template field-ID capture in admin settings and is deferred. Two per-port v2 settings now wired through `buildDocumensoPayload` + `documensoCreate.meta`: `documenso_signing_order` (PARALLEL/SEQUENTIAL — v2-enforced) and `documenso_redirect_url` (post-sign redirect; both versions honour). `checkDocumensoHealth` returns the resolved `apiVersion` for the admin Test button. - **Documenso v1 vs v2 endpoint routing:** `getPortDocumensoConfig(portId)` resolves the per-port `apiVersion` ('v1' | 'v2'). `documenso-client.ts` exports version-aware wrappers: `getDocument`, `createDocument`, `sendDocument`, `sendReminder`, `downloadSignedPdf`, `voidDocument`, `placeFields`. v2 → `/api/v2/envelope/*` (`create` is multipart with `{payload, files}`; `distribute` returns per-recipient `signingUrl` in one round-trip; `redistribute` for reminders; `field/create-many` for bulk placement with percent coords + `fieldMeta`). v1 → existing `/api/v1/documents/*` paths. **Template flow is intentionally still v1** (`/api/v1/templates/{id}/generate-document` with `formValues` keyed by name) — v2 instances accept it via backward compat. Full v2 `/template/use` migration with `prefillFields` by ID needs per-template field-ID capture in admin settings and is deferred. Two per-port v2 settings now wired through `buildDocumensoPayload` + `documensoCreate.meta`: `documenso_signing_order` (PARALLEL/SEQUENTIAL — v2-enforced) and `documenso_redirect_url` (post-sign redirect; both versions honour). `checkDocumensoHealth` returns the resolved `apiVersion` for the admin Test button.
- **Email templates:** Branded HTML lives in `src/lib/email/templates/`. The portal-auth flow uses `portal-auth.ts` (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and `width:100%` for responsive shrink. The `<img>` URLs reference `s3.portnimara.com` directly (will move to `/public` later). - **Email templates:** Branded HTML lives in `src/lib/email/templates/`. The portal-auth flow uses `portal-auth.ts` (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and `width:100%` for responsive shrink. The `<img>` URLs reference `s3.portnimara.com` directly (will move to `/public` later).
- **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `<BrandedAuthShell>` (`src/components/shared/branded-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified. - **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `<BrandedAuthShell>` (`src/components/shared/branded-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified.
- **Sheet vs Drawer doctrine:** `<Sheet side="right">` (`src/components/ui/sheet.tsx`, Radix dialog) is the canonical side-panel for forms and previews on **both** desktop and mobile (`w-3/4 ... sm:max-w-sm` adapts naturally). Vaul `<Drawer>` (`src/components/shared/drawer.tsx`) is reserved for **mobile-only bottom-sheet UX** — currently just the `MoreSheet` nav (`src/components/layout/mobile/more-sheet.tsx`). If you need a side panel of any kind, use Sheet. Don't add new Vaul drawers without a mobile-bottom-sheet justification.
- **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `<InlineEditableField>` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `<InlineTagEditor>` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1/<entity>/[id]/tags` endpoint backed by a `set<Entity>Tags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place. - **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `<InlineEditableField>` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `<InlineTagEditor>` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1/<entity>/[id]/tags` endpoint backed by a `set<Entity>Tags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place.
- **Notes (polymorphic across entity types):** `notes.service.ts` dispatches across `clientNotes`, `interestNotes`, `yachtNotes`, `companyNotes` based on an `entityType` discriminator. `<NotesList entityType="…" />` works for all four. `companyNotes` lacks an `updatedAt` column — the service substitutes `createdAt` so callers get a uniform shape. - **Notes (polymorphic across entity types):** `notes.service.ts` dispatches across `clientNotes`, `interestNotes`, `yachtNotes`, `companyNotes` based on an `entityType` discriminator. `<NotesList entityType="…" />` works for all four. `companyNotes` lacks an `updatedAt` column — the service substitutes `createdAt` so callers get a uniform shape.
- **Document folders:** Per-port nestable tree (`document_folders` self-FK on `parent_id`; null parent = root). Documents and files carry a nullable `folder_id` (null = root). Sibling-name uniqueness via `uniq_document_folders_sibling_name` on `(port_id, COALESCE(parent_id, '__root__'), LOWER(name))`. Folder delete is **soft rescue**: `deleteFolderSoftRescue` re-parents every child folder + document + file up to the deleted folder's parent (or to root) inside a transaction, then drops the folder row — never CASCADE. Cycle prevention in `moveFolder` walks the destination's ancestor chain. - **Document folders:** Per-port nestable tree (`document_folders` self-FK on `parent_id`; null parent = root). Documents and files carry a nullable `folder_id` (null = root). Sibling-name uniqueness via `uniq_document_folders_sibling_name` on `(port_id, COALESCE(parent_id, '__root__'), LOWER(name))`. Folder delete is **soft rescue**: `deleteFolderSoftRescue` re-parents every child folder + document + file up to the deleted folder's parent (or to root) inside a transaction, then drops the folder row — never CASCADE. Cycle prevention in `moveFolder` walks the destination's ancestor chain.

View File

@@ -227,14 +227,14 @@ Roughly half-day each; ship in priority order. These are the items from the audi
14. **PII redaction in error pipeline**`error_events.request_body_excerpt` sanitizer redacts password/token but not email/phone/name/dob/address. ~2 h. **(observability H + gdpr)** 14. **PII redaction in error pipeline**`error_events.request_body_excerpt` sanitizer redacts password/token but not email/phone/name/dob/address. ~2 h. **(observability H + gdpr)**
15. **Notification email worker XSS**`src/lib/queue/workers/notifications.ts:65-71` interpolates `notif.description` and `notif.link` into HTML unescaped. Apply `escapeHtml` + URL allow-list (the `isomorphic-dompurify` we shipped helps here). ~1 h. **(email H + security)** 15. **Notification email worker XSS**`src/lib/queue/workers/notifications.ts:65-71` interpolates `notif.description` and `notif.link` into HTML unescaped. Apply `escapeHtml` + URL allow-list (the `isomorphic-dompurify` we shipped helps here). ~1 h. **(email H + security)**
### Wave 3 — React Compiler set-state-in-effect cleanup (~41 sites) ### Wave 3 — React Compiler set-state-in-effect cleanup (~40 sites remaining)
Remaining 41 `react-hooks/set-state-in-effect` warnings. Two patterns established this session as templates: Remaining `react-hooks/set-state-in-effect` warnings: **40** (was 41; reduced 2026-05-13). Two patterns established this session as templates:
- **List/load pattern** (`src/components/admin/tags/tag-list.tsx` is the template): `useState([]) + useEffect(fetch+setState)``useQuery({ queryKey, queryFn })`. Mutation paths get `useMutation` with `onSuccess: queryClient.invalidateQueries`. ~10 min per site. - **List/load pattern** (`src/components/admin/tags/tag-list.tsx` is the template): `useState([]) + useEffect(fetch+setState)``useQuery({ queryKey, queryFn })`. Mutation paths get `useMutation` with `onSuccess: queryClient.invalidateQueries`. ~10 min per site.
- **Dialog open→reset pattern** (`src/components/clients/hard-delete-dialog.tsx` is the template): inner `<DialogBody key={id} ... />` mounted only while `open`, so `useState` initializers run naturally on each open without an open→reset useEffect. ~15 min per site. - **Dialog open→reset pattern** (`src/components/clients/hard-delete-dialog.tsx` is the template; new exemplar: `src/components/documents/move-to-folder-dialog.tsx`): inner `<DialogBody key={id} ... />` mounted only while `open`, so `useState` initializers run naturally on each open without an open→reset useEffect. ~15 min per site.
Migrate as a focused day's work, then promote `react-hooks/set-state-in-effect` from `warn` to `error` in `eslint.config.mjs` to lock in. Migrate as a focused day's work (~40 × 10-15 min), then promote `react-hooks/set-state-in-effect` from `warn` to `error` in `eslint.config.mjs` to lock in. **NOTE:** Warnings only — no functional regressions; promotion blocked solely until 0 warnings remain.
### Wave 4 — UI/UX consistency + accessibility (~3-4 days) ### Wave 4 — UI/UX consistency + accessibility (~3-4 days)

View File

@@ -9,15 +9,13 @@ const eslintConfig = [
'@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
// React Compiler safety rules shipped with eslint-config-next@16 / // React Compiler safety rules shipped with eslint-config-next@16 /
// react-hooks@7. Triage status (2026-05-12 sweep): // react-hooks@7. Triage status (2026-05-13 sweep):
// purity, set-state-in-render, immutability, refs — promoted // purity, set-state-in-render, immutability, refs,
// back to error after the existing hits were cleaned up; new // set-state-in-effect — promoted to error after the cleanup
// regressions block CI. // sweep (Wave 3 of the 2026-05-12 audit). All hits migrated to
// set-state-in-effect — left as warn. Many hits are the // either useQuery, render-phase derivation, key-based remount,
// useEffect→fetch→setState data-loading pattern that the // or a justified eslint-disable for canonical setState-on-
// Compiler conservatively flags but can't refactor without // subscription patterns. New regressions block CI.
// moving each call site to TanStack Query. ~50 admin-form
// land sites tracked in docs/BACKLOG.md §G.
// incompatible-library — informational only ("Compiler // incompatible-library — informational only ("Compiler
// skipped this file because of a non-Compiler-safe import"). // skipped this file because of a non-Compiler-safe import").
// No action needed; silenced to keep `pnpm lint` output // No action needed; silenced to keep `pnpm lint` output
@@ -26,7 +24,7 @@ const eslintConfig = [
'react-hooks/set-state-in-render': 'error', 'react-hooks/set-state-in-render': 'error',
'react-hooks/immutability': 'error', 'react-hooks/immutability': 'error',
'react-hooks/refs': 'error', 'react-hooks/refs': 'error',
'react-hooks/set-state-in-effect': 'warn', 'react-hooks/set-state-in-effect': 'error',
'react-hooks/incompatible-library': 'off', 'react-hooks/incompatible-library': 'off',
}, },
}, },

View File

@@ -7,7 +7,6 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { authClient } from '@/lib/auth/client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -15,9 +14,10 @@ import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell'; import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
// `identifier` accepts either an email address or a username (330 lowercase // `identifier` accepts either an email address or a username (330 lowercase
// letters / digits / dot / underscore / hyphen). The page resolves usernames // letters / digits / dot / underscore / hyphen). The server endpoint
// to the canonical Better-Auth email via /api/auth/resolve-identifier before // /api/auth/sign-in-by-identifier resolves the username server-side and
// the actual sign-in call. // forwards to better-auth in one round-trip — the canonical email is never
// returned to the browser, which closes the username-enumeration vector.
const loginSchema = z.object({ const loginSchema = z.object({
identifier: z.string().min(1, 'Email or username is required'), identifier: z.string().min(1, 'Email or username is required'),
password: z.string().min(1, 'Password is required'), password: z.string().min(1, 'Password is required'),
@@ -40,29 +40,20 @@ export default function LoginPage() {
async function onSubmit(data: LoginFormData) { async function onSubmit(data: LoginFormData) {
setIsLoading(true); setIsLoading(true);
try { try {
// Resolve username → email when the input isn't already an email. const res = await fetch('/api/auth/sign-in-by-identifier', {
// The endpoint always returns SOMETHING (the input itself on miss) method: 'POST',
// so the auth call below fails uniformly with "invalid credentials" headers: { 'Content-Type': 'application/json' },
// either way — no username enumeration. body: JSON.stringify({
const identifier = data.identifier.trim(); identifier: data.identifier.trim(),
let email = identifier; password: data.password,
if (!identifier.includes('@')) { }),
const res = await fetch('/api/auth/resolve-identifier', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier }),
});
const payload = (await res.json().catch(() => ({}))) as { email?: string };
email = payload.email?.trim() || identifier;
}
const result = await authClient.signIn.email({
email,
password: data.password,
}); });
if (result.error) { if (!res.ok) {
toast.error(result.error.message ?? 'Invalid credentials'); const payload = (await res.json().catch(() => ({}))) as {
error?: { message?: string };
};
toast.error(payload.error?.message ?? 'Invalid credentials');
return; return;
} }

View File

@@ -30,22 +30,6 @@ const FIELDS: SettingFieldDef[] = [
placeholder: 'sales@example.com', placeholder: 'sales@example.com',
defaultValue: '', defaultValue: '',
}, },
{
key: 'email_signature_html',
label: 'Default signature (HTML)',
description: 'Appended to the bottom of system-generated emails.',
type: 'html',
placeholder: '<p>-<br>The Port Nimara team</p>',
defaultValue: '',
},
{
key: 'email_footer_html',
label: 'Email footer (HTML)',
description: 'Legal/contact footer rendered at the very bottom of all emails.',
type: 'html',
placeholder: '<p style="font-size:11px;color:#888;">© Port Nimara · ul. ...</p>',
defaultValue: '',
},
{ {
key: 'smtp_host_override', key: 'smtp_host_override',
label: 'SMTP host override', label: 'SMTP host override',
@@ -83,17 +67,17 @@ export default function EmailSettingsPage() {
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="Email Settings" title="Email Settings"
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank." description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
/> />
<SettingsFormCard <SettingsFormCard
title="From address & signature" title="From address"
description="Identity headers and shared HTML used by system-generated emails." description="Identity headers used by system-generated emails."
fields={FIELDS.slice(0, 5)} fields={FIELDS.slice(0, 3)}
/> />
<SettingsFormCard <SettingsFormCard
title="SMTP transport overrides" title="SMTP transport overrides"
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults." description="Optional per-port SMTP credentials. Leave blank to use the global env defaults."
fields={FIELDS.slice(5)} fields={FIELDS.slice(3)}
/> />
<SalesEmailConfigCard /> <SalesEmailConfigCard />
</div> </div>

View File

@@ -19,7 +19,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { EXPENSE_CATEGORIES } from '@/lib/constants'; import { EXPENSE_CATEGORIES, formatEnum } from '@/lib/constants';
interface ScanResult { interface ScanResult {
establishment: string | null; establishment: string | null;
@@ -345,7 +345,7 @@ export default function ScanReceiptPage() {
<SelectContent> <SelectContent>
{EXPENSE_CATEGORIES.map((cat) => ( {EXPENSE_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}> <SelectItem key={cat} value={cat}>
{cat.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())} {formatEnum(cat)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

View File

@@ -1,90 +0,0 @@
/**
* Resolves an email-or-username sign-in identifier to a canonical email
* that Better Auth's email/password flow accepts.
*
* Public endpoint by design — the login form calls it BEFORE the user is
* authenticated, so it can't sit behind `withAuth`.
*
* **Anti-enumeration:** the response shape is identical for hit and
* miss. On a miss we return a synthetic `@auth.invalid` email derived
* from the input so Better Auth's `signIn.email` call fails uniformly
* with "invalid credentials" — an attacker can't tell whether the
* username exists from this endpoint's response. (Previously a miss
* returned the bare input string, which lacked an `@` and was visibly
* different from a hit's real email.)
*
* **Rate limiting:** shares the `auth` bucket (5/15min/ip), so an
* attacker can't iterate a wordlist faster than they could brute-force
* passwords directly.
*/
import { NextResponse, type NextRequest } from 'next/server';
import { sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, userProfiles } from '@/lib/db/schema/users';
import { eq } from 'drizzle-orm';
import { checkRateLimit, rateLimitHeaders, rateLimiters } from '@/lib/rate-limit';
const EMAIL_HINT = /@/;
/** Synthetic, definitively-invalid email used for the miss path. The
* `.invalid` TLD is reserved by RFC 2606 — no real domain can use it,
* so a downstream signIn call always fails as "invalid credentials"
* without ever leaking the lookup outcome. */
function syntheticEmail(raw: string): string {
const slug = raw.replace(/[^a-z0-9._-]/gi, '').slice(0, 30) || 'unknown';
return `${slug}@auth.invalid`;
}
function clientIp(req: NextRequest): string {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
req.headers.get('x-real-ip') ??
'unknown'
);
}
export async function POST(req: NextRequest) {
try {
// Rate-limit on IP — same 5/15min bucket the actual sign-in uses.
// Without this an attacker can wordlist usernames at full HTTP
// bandwidth and only funnel the validated emails into the slower
// signIn flow.
const ip = clientIp(req);
const rl = await checkRateLimit(ip, rateLimiters.auth);
if (!rl.allowed) {
return NextResponse.json({ email: '' }, { status: 429, headers: rateLimitHeaders(rl) });
}
const body = (await req.json().catch(() => ({}))) as { identifier?: string };
const raw = (body.identifier ?? '').trim();
if (!raw) return NextResponse.json({ email: syntheticEmail('empty') });
// Looks like an email → already canonical. Hand it straight back.
if (EMAIL_HINT.test(raw)) {
return NextResponse.json({ email: raw });
}
// Otherwise treat the input as a username and look up the linked
// Better Auth email. Case-insensitive match against the
// `LOWER(username)` unique index.
const normalized = raw.toLowerCase();
const rows = await db
.select({ email: user.email })
.from(userProfiles)
.innerJoin(user, eq(userProfiles.userId, user.id))
.where(sql`LOWER(${userProfiles.username}) = ${normalized}`)
.limit(1);
if (rows.length === 0) {
// Synthetic `.invalid` email — indistinguishable from a hit in
// shape (has `@`, has a tld), guaranteed to fail downstream auth.
return NextResponse.json({ email: syntheticEmail(normalized) });
}
return NextResponse.json({ email: rows[0]!.email });
} catch {
// Defensive — never expose internals from a public endpoint.
return NextResponse.json({ email: syntheticEmail('error') }, { status: 200 });
}
}

View File

@@ -0,0 +1,102 @@
/**
* Server-side sign-in endpoint that accepts an email-or-username
* `identifier`. The username → email resolution happens entirely server-
* side, so the canonical email is never disclosed to the browser. This
* closes the username-enumeration vector that the old
* `/api/auth/resolve-identifier` endpoint left open (it echoed the real
* email on a hit; a synthetic `@auth.invalid` email on a miss was
* trivially distinguishable from a real one by domain).
*
* The endpoint POSTs to better-auth's `/api/auth/sign-in/email`
* downstream so the response shape (cookies + JSON body) matches what
* the existing client expects.
*/
import { NextResponse, type NextRequest } from 'next/server';
import { sql, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, userProfiles } from '@/lib/db/schema/users';
import { checkRateLimit, rateLimitHeaders, rateLimiters } from '@/lib/rate-limit';
function clientIp(req: NextRequest): string {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
req.headers.get('x-real-ip') ??
'unknown'
);
}
async function resolveToEmail(identifier: string): Promise<string | null> {
const raw = identifier.trim();
if (!raw) return null;
if (raw.includes('@')) return raw;
const normalized = raw.toLowerCase();
const rows = await db
.select({ email: user.email })
.from(userProfiles)
.innerJoin(user, eq(userProfiles.userId, user.id))
.where(sql`LOWER(${userProfiles.username}) = ${normalized}`)
.limit(1);
return rows[0]?.email ?? null;
}
export async function POST(req: NextRequest) {
// Rate-limit on IP — same 5/15min bucket the sign-in endpoint uses.
const ip = clientIp(req);
const rl = await checkRateLimit(ip, rateLimiters.auth);
if (!rl.allowed) {
return NextResponse.json(
{ error: { message: 'Too many attempts. Try again later.' } },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
const body = (await req.json().catch(() => ({}))) as {
identifier?: string;
password?: string;
rememberMe?: boolean;
callbackURL?: string;
};
const identifier = (body.identifier ?? '').trim();
const password = body.password ?? '';
if (!identifier || !password) {
// Match better-auth's invalid-credentials shape so the client can
// surface a uniform error without distinguishing the failure mode.
return NextResponse.json(
{ error: { message: 'Invalid credentials', code: 'INVALID_EMAIL_OR_PASSWORD' } },
{ status: 401 },
);
}
const email = await resolveToEmail(identifier);
// On a username miss we still call better-auth with a guaranteed-fail
// email so the timing and response shape match the hit-with-wrong-
// password path. The `.invalid` TLD is reserved by RFC 2606 so no real
// user could ever match it.
const effectiveEmail =
email ?? `${identifier.replace(/[^a-z0-9._-]/gi, '').slice(0, 30) || 'unknown'}@auth.invalid`;
// Forward to better-auth's existing sign-in endpoint. We construct a
// fresh Request because Next.js's NextRequest is read-only.
const url = new URL('/api/auth/sign-in/email', req.url);
const forwardBody = JSON.stringify({
email: effectiveEmail,
password,
rememberMe: body.rememberMe,
callbackURL: body.callbackURL,
});
const forwardReq = new Request(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Preserve client metadata for audit / rate limiting downstream.
'x-forwarded-for': req.headers.get('x-forwarded-for') ?? ip,
'user-agent': req.headers.get('user-agent') ?? '',
cookie: req.headers.get('cookie') ?? '',
},
body: forwardBody,
});
const { POST: signInHandler } = await import('@/app/api/auth/[...all]/route');
return signInHandler(forwardReq as NextRequest);
}

View File

@@ -17,6 +17,7 @@ import { logger } from '@/lib/logger';
import { createAuditLog } from '@/lib/audit'; import { createAuditLog } from '@/lib/audit';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit'; import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
import { captureErrorEvent } from '@/lib/services/error-events.service'; import { captureErrorEvent } from '@/lib/services/error-events.service';
import { withPublicContext } from '@/lib/api/helpers';
// BR-024: Dedup via signatureHash unique index on documentEvents // BR-024: Dedup via signatureHash unique index on documentEvents
// Always return 200 from webhook (webhook best practice) // Always return 200 from webhook (webhook best practice)
@@ -83,7 +84,7 @@ type DocumensoWebhookBody = {
}; };
}; };
export async function POST(req: NextRequest): Promise<NextResponse> { async function handleDocumensoWebhook(req: NextRequest): Promise<NextResponse> {
let rawBody: string; let rawBody: string;
try { try {
@@ -296,3 +297,9 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
return NextResponse.json({ ok: true }, { status: 200 }); return NextResponse.json({ ok: true }, { status: 200 });
} }
// Wrap with withPublicContext so the handler runs inside a
// runWithRequestContext ALS frame — without it the inline
// `captureErrorEvent` call in the catch block silently no-ops because
// getRequestContext() returns null for unauthenticated routes.
export const POST = withPublicContext(handleDocumensoWebhook);

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
@@ -40,19 +40,32 @@ export function AiBudgetCard() {
queryKey, queryKey,
queryFn: () => apiFetch<BudgetResp>('/api/v1/admin/ai-budget'), queryFn: () => apiFetch<BudgetResp>('/api/v1/admin/ai-budget'),
}); });
// Key-based remount: the form body is keyed on the loaded payload
// signature so its useState initializers seed from server data on
// first load. Replaces the prior useEffect(setState, [data]) sync.
const sig = data?.data
? `${data.data.budget.enabled}:${data.data.budget.softCapTokens}:${data.data.budget.hardCapTokens}:${data.data.budget.period}`
: 'loading';
return (
<AiBudgetCardBody key={sig} data={data} isLoading={isLoading} qc={qc} queryKey={queryKey} />
);
}
const [enabled, setEnabled] = useState(false); function AiBudgetCardBody({
const [softCap, setSoftCap] = useState('100000'); data,
const [hardCap, setHardCap] = useState('500000'); isLoading,
const [period, setPeriod] = useState<Period>('month'); qc,
queryKey,
useEffect(() => { }: {
if (!data?.data) return; data: BudgetResp | undefined;
setEnabled(data.data.budget.enabled); isLoading: boolean;
setSoftCap(String(data.data.budget.softCapTokens)); qc: ReturnType<typeof useQueryClient>;
setHardCap(String(data.data.budget.hardCapTokens)); queryKey: string[];
setPeriod(data.data.budget.period); }) {
}, [data?.data]); const [enabled, setEnabled] = useState(data?.data.budget.enabled ?? false);
const [softCap, setSoftCap] = useState(data ? String(data.data.budget.softCapTokens) : '100000');
const [hardCap, setHardCap] = useState(data ? String(data.data.budget.hardCapTokens) : '500000');
const [period, setPeriod] = useState<Period>(data?.data.budget.period ?? 'month');
const save = useMutation({ const save = useMutation({
mutationFn: () => mutationFn: () =>

View File

@@ -187,6 +187,11 @@ export function AuditLogList() {
}, [queryString, nextCursor]); }, [queryString, nextCursor]);
useEffect(() => { useEffect(() => {
// Refetch on filter change. Migrating this list to useInfiniteQuery
// would be the proper fix but is deferred — the fetch-on-effect
// pattern here is functionally correct and gated by the queryString
// memo so it only fires when filters actually change.
// eslint-disable-next-line react-hooks/set-state-in-effect
void fetchFirstPage(); void fetchFirstPage();
}, [fetchFirstPage]); }, [fetchFirstPage]);

View File

@@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useConfirmation } from '@/hooks/use-confirmation';
const ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,image/heic,image/heif,image/avif'; const ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,image/heic,image/heif,image/avif';
@@ -54,6 +55,7 @@ function centeredCrop(width: number, height: number, aspect: number): Crop {
} }
export function PdfLogoUploader() { export function PdfLogoUploader() {
const { confirm, dialog: confirmDialog } = useConfirmation();
const [current, setCurrent] = useState<CurrentLogo | null>(null); const [current, setCurrent] = useState<CurrentLogo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false); const [working, setWorking] = useState(false);
@@ -160,7 +162,12 @@ export function PdfLogoUploader() {
} }
async function clear() { async function clear() {
if (!confirm('Remove the PDF logo? Future reports will fall back to the port name.')) return; const ok = await confirm({
title: 'Remove PDF logo',
description: 'Remove the PDF logo? Future reports will fall back to the port name.',
confirmLabel: 'Remove',
});
if (!ok) return;
setWorking(true); setWorking(true);
try { try {
const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE' }); const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE' });
@@ -334,6 +341,7 @@ export function PdfLogoUploader() {
) : null} ) : null}
</div> </div>
) : null} ) : null}
{confirmDialog}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { formatErrorBanner } from '@/lib/api/toast-error'; import { formatErrorBanner } from '@/lib/api/toast-error';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Plus, X } from 'lucide-react'; import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -60,7 +60,18 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
// ─── Component ──────────────────────────────────────────────────────────────── // ─── Component ────────────────────────────────────────────────────────────────
export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: CustomFieldFormProps) { export function CustomFieldForm(props: CustomFieldFormProps) {
// Key-based remount: the body is keyed on open + field.id so its
// useState initializers re-seed each time the dialog opens.
return (
<CustomFieldFormBody
key={props.open ? `open:${props.field?.id ?? 'new'}` : 'closed'}
{...props}
/>
);
}
function CustomFieldFormBody({ open, onOpenChange, field, onSuccess }: CustomFieldFormProps) {
const isEdit = !!field; const isEdit = !!field;
// Form state // Form state
@@ -75,20 +86,7 @@ export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: Custom
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Reset state when dialog opens // Reset is handled by the parent key-based remount above.
useEffect(() => {
if (open) {
setEntityType(field?.entityType ?? 'client');
setFieldName(field?.fieldName ?? '');
setFieldLabel(field?.fieldLabel ?? '');
setFieldType(field?.fieldType ?? 'text');
setSelectOptions(field?.selectOptions ?? []);
setNewOption('');
setIsRequired(field?.isRequired ?? false);
setSortOrder(field?.sortOrder ?? 0);
setError(null);
}
}, [open, field]);
// ── Select options management ────────────────────────────────────────────── // ── Select options management ──────────────────────────────────────────────

View File

@@ -11,6 +11,7 @@ import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { WarningCallout } from '@/components/ui/warning-callout';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { CustomFieldForm, type CustomFieldDefinition } from './custom-field-form'; import { CustomFieldForm, type CustomFieldDefinition } from './custom-field-form';
@@ -164,15 +165,16 @@ export function CustomFieldsManager() {
} }
/> />
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2.5 text-xs text-amber-900"> <WarningCallout title="Heads up">
<strong>Heads up:</strong> custom fields render in detail-page sidebars and the entity <span className="text-xs">
export, and merge-tokens of the form{' '} Custom fields render in detail-page sidebars and the entity export, and merge-tokens of
<code className="rounded bg-amber-100 px-1">{`{{custom.fieldName}}`}</code> now expand in the form <code className="rounded bg-amber-100 px-1">{`{{custom.fieldName}}`}</code> now
EOI/contract/email templates for client/interest/berth contexts. They still don&rsquo;t plug expand in EOI/contract/email templates for client/interest/berth contexts. They still
into the global search index, the berth recommender, or the entity-diff audit log use them don&rsquo;t plug into the global search index, the berth recommender, or the entity-diff
for rep-only annotations and template-merge values, but anything load-bearing for the deal audit log use them for rep-only annotations and template-merge values, but anything
flow still needs a first-class column. load-bearing for the deal flow still needs a first-class column.
</div> </span>
</WarningCallout>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as EntityTab)}> <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as EntityTab)}>
<TabsList> <TabsList>

View File

@@ -6,6 +6,7 @@ import { RotateCcw, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { useConfirmation } from '@/hooks/use-confirmation';
interface TemplateVersion { interface TemplateVersion {
version: number; version: number;
@@ -27,6 +28,7 @@ export function TemplateVersionHistory({
onRollback, onRollback,
}: TemplateVersionHistoryProps) { }: TemplateVersionHistoryProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const queryKey = ['admin', 'template-versions', templateId] as const; const queryKey = ['admin', 'template-versions', templateId] as const;
const [rollingBack, setRollingBack] = useState<number | null>(null); const [rollingBack, setRollingBack] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -47,12 +49,12 @@ export function TemplateVersionHistory({
const effectiveError = error ?? (queryError instanceof Error ? queryError.message : null); const effectiveError = error ?? (queryError instanceof Error ? queryError.message : null);
async function handleRollback(version: number) { async function handleRollback(version: number) {
if ( const ok = await confirm({
!confirm( title: `Roll back to version ${version}`,
`Roll back to version ${version}? This will create a new version ${currentVersion + 1}.`, description: `This will create a new version ${currentVersion + 1}.`,
) confirmLabel: 'Restore',
) });
return; if (!ok) return;
setRollingBack(version); setRollingBack(version);
setError(null); setError(null);
@@ -133,6 +135,7 @@ export function TemplateVersionHistory({
</div> </div>
))} ))}
</div> </div>
{confirmDialog}
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { RotateCcw, Save } from 'lucide-react'; import { RotateCcw, Save } from 'lucide-react';
@@ -27,23 +27,40 @@ export function EmailTemplatesAdmin() {
queryKey: ['admin-email-templates'], queryKey: ['admin-email-templates'],
queryFn: () => apiFetch<{ data: TemplateRow[] }>('/api/v1/admin/email-templates'), queryFn: () => apiFetch<{ data: TemplateRow[] }>('/api/v1/admin/email-templates'),
}); });
// Key-based remount: re-mount the body when the server-loaded row
const [drafts, setDrafts] = useState<Record<string, string>>({}); // signature changes so its useState seeds from fresh server data.
const [savingKey, setSavingKey] = useState<string | null>(null); // Replaces the prior useEffect(setDrafts, [rows]) sync.
const [message, setMessage] = useState<{ key: string; kind: 'ok' | 'err'; text: string } | null>( const sig = data?.data
null, ? data.data.map((r) => `${r.key}:${r.subjectOverride ?? r.defaultSubject}`).join('|')
: 'loading';
return (
<EmailTemplatesAdminBody key={sig} data={data} isLoading={isLoading} error={error} qc={qc} />
); );
}
function EmailTemplatesAdminBody({
data,
isLoading,
error,
qc,
}: {
data: { data: TemplateRow[] } | undefined;
isLoading: boolean;
error: unknown;
qc: ReturnType<typeof useQueryClient>;
}) {
const rows = useMemo(() => data?.data ?? [], [data]); const rows = useMemo(() => data?.data ?? [], [data]);
const [drafts, setDrafts] = useState<Record<string, string>>(() => {
useEffect(() => {
// Hydrate drafts from server values whenever the source-of-truth list refreshes.
const next: Record<string, string> = {}; const next: Record<string, string> = {};
for (const row of rows) { for (const row of rows) {
next[row.key] = row.subjectOverride ?? row.defaultSubject; next[row.key] = row.subjectOverride ?? row.defaultSubject;
} }
setDrafts(next); return next;
}, [rows]); });
const [savingKey, setSavingKey] = useState<string | null>(null);
const [message, setMessage] = useState<{ key: string; kind: 'ok' | 'err'; text: string } | null>(
null,
);
async function save(row: TemplateRow, mode: 'save' | 'reset') { async function save(row: TemplateRow, mode: 'save' | 'reset') {
setSavingKey(row.key); setSavingKey(row.key);

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -54,25 +54,23 @@ const FIELD_TYPES: Array<{ value: FormField['type']; label: string }> = [
{ value: 'checkbox', label: 'Checkbox' }, { value: 'checkbox', label: 'Checkbox' },
]; ];
export function FormTemplateForm({ open, onOpenChange, template, onSaved }: Props) { export function FormTemplateForm(props: Props) {
const [name, setName] = useState(''); // Key-based remount seeds state on each open + template change.
const [description, setDescription] = useState(''); return (
const [isActive, setIsActive] = useState(true); <FormTemplateFormBody
const [fields, setFields] = useState<FormField[]>([{ ...DEFAULT_FIELD }]); key={props.open ? `open:${props.template?.id ?? 'new'}` : 'closed'}
{...props}
/>
);
}
useEffect(() => { function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props) {
if (template) { const [name, setName] = useState(template?.name ?? '');
setName(template.name); const [description, setDescription] = useState(template?.description ?? '');
setDescription(template.description ?? ''); const [isActive, setIsActive] = useState(template?.isActive ?? true);
setIsActive(template.isActive); const [fields, setFields] = useState<FormField[]>(
setFields(template.fields.length > 0 ? template.fields : [{ ...DEFAULT_FIELD }]); template && template.fields.length > 0 ? template.fields : [{ ...DEFAULT_FIELD }],
} else { );
setName('');
setDescription('');
setIsActive(true);
setFields([{ ...DEFAULT_FIELD }]);
}
}, [template, open]);
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: () => { mutationFn: () => {

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Eye, EyeOff, Loader2, XCircle } from 'lucide-react'; import { CheckCircle2, Eye, EyeOff, Loader2, XCircle } from 'lucide-react';
@@ -44,33 +44,55 @@ interface SettingsBlockProps {
showUseGlobal?: boolean; showUseGlobal?: boolean;
} }
function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlockProps) { function SettingsBlock(props: SettingsBlockProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const queryKey = ['ocr-settings', scope]; const queryKey = ['ocr-settings', props.scope];
const { data, isLoading } = useQuery<ConfigResp>({ const { data, isLoading } = useQuery<ConfigResp>({
queryKey, queryKey,
queryFn: () => apiFetch<ConfigResp>(`/api/v1/admin/ocr-settings?scope=${scope}`), queryFn: () => apiFetch<ConfigResp>(`/api/v1/admin/ocr-settings?scope=${props.scope}`),
}); });
// Key the body on the loaded payload so useState initializers seed
// from server values cleanly.
const sig = data?.data
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}`
: 'loading';
return (
<SettingsBlockBody
key={sig}
{...props}
data={data}
isLoading={isLoading}
queryClient={queryClient}
queryKey={queryKey}
/>
);
}
const [provider, setProvider] = useState<Provider>('openai'); function SettingsBlockBody({
const [model, setModel] = useState<string>('gpt-4o-mini'); scope,
title,
description,
showUseGlobal,
data,
isLoading,
queryClient,
queryKey,
}: SettingsBlockProps & {
data: ConfigResp | undefined;
isLoading: boolean;
queryClient: ReturnType<typeof useQueryClient>;
queryKey: (string | Scope)[];
}) {
const [provider, setProvider] = useState<Provider>(data?.data.provider ?? 'openai');
const [model, setModel] = useState<string>(data?.data.model ?? 'gpt-4o-mini');
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [useGlobal, setUseGlobal] = useState(false); const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
const [aiEnabled, setAiEnabled] = useState(false); const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false);
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>( const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
null, null,
); );
useEffect(() => {
if (!data?.data) return;
setProvider(data.data.provider);
setModel(data.data.model);
setUseGlobal(data.data.useGlobal);
setAiEnabled(data.data.aiEnabled);
}, [data?.data]);
const save = useMutation({ const save = useMutation({
mutationFn: (clearApiKey?: boolean) => mutationFn: (clearApiKey?: boolean) =>
apiFetch('/api/v1/admin/ocr-settings', { apiFetch('/api/v1/admin/ocr-settings', {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { formatErrorBanner } from '@/lib/api/toast-error'; import { formatErrorBanner } from '@/lib/api/toast-error';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -50,39 +50,24 @@ interface PortFormProps {
onSuccess: () => void; onSuccess: () => void;
} }
export function PortForm({ open, onOpenChange, port, onSuccess }: PortFormProps) { export function PortForm(props: PortFormProps) {
const [name, setName] = useState(''); return (
const [slug, setSlug] = useState(''); <PortFormBody key={props.open ? `open:${props.port?.id ?? 'new'}` : 'closed'} {...props} />
const [primaryColor, setPrimaryColor] = useState('#0F4C81'); );
const [defaultCurrency, setDefaultCurrency] = useState('USD'); }
const [timezone, setTimezone] = useState('America/Anguilla');
const [isActive, setIsActive] = useState(true); function PortFormBody({ open, onOpenChange, port, onSuccess }: PortFormProps) {
const [name, setName] = useState(port?.name ?? '');
const [slug, setSlug] = useState(port?.slug ?? '');
const [primaryColor, setPrimaryColor] = useState(port?.primaryColor ?? '#0F4C81');
const [defaultCurrency, setDefaultCurrency] = useState(port?.defaultCurrency ?? 'USD');
const [timezone, setTimezone] = useState(port?.timezone ?? 'America/Anguilla');
const [isActive, setIsActive] = useState(port?.isActive ?? true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isEdit = !!port; const isEdit = !!port;
useEffect(() => {
if (open) {
if (port) {
setName(port.name);
setSlug(port.slug);
setPrimaryColor(port.primaryColor ?? '#0F4C81');
setDefaultCurrency(port.defaultCurrency);
setTimezone(port.timezone);
setIsActive(port.isActive);
} else {
setName('');
setSlug('');
setPrimaryColor('#0F4C81');
setDefaultCurrency('USD');
setTimezone('America/Anguilla');
setIsActive(true);
}
setError(null);
}
}, [open, port]);
function handleNameChange(value: string) { function handleNameChange(value: string) {
setName(value); setName(value);
if (!isEdit) { if (!isEdit) {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { GripVertical, Plus, Trash2, Loader2, Save, AlertTriangle } from 'lucide-react'; import { GripVertical, Plus, Trash2, Loader2, Save } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -22,6 +22,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { WarningCallout } from '@/components/ui/warning-callout';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -235,11 +236,10 @@ export function ResidentialStagesAdmin() {
</Card> </Card>
{removedStageIds.length > 0 && ( {removedStageIds.length > 0 && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900"> <WarningCallout>
<AlertTriangle className="mr-2 inline h-4 w-4" />
Removing: {removedStageIds.join(', ')}. Any interests parked on these stages will need to Removing: {removedStageIds.join(', ')}. Any interests parked on these stages will need to
be reassigned before save. be reassigned before save.
</div> </WarningCallout>
)} )}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { formatErrorBanner } from '@/lib/api/toast-error'; import { formatErrorBanner } from '@/lib/api/toast-error';
import { formatEnum } from '@/lib/constants';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -120,7 +121,7 @@ const GROUP_LABELS: Record<string, string> = {
}; };
function formatAction(action: string): string { function formatAction(action: string): string {
return action.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); return formatEnum(action);
} }
interface RoleFormProps { interface RoleFormProps {
@@ -136,41 +137,36 @@ interface RoleFormProps {
onSuccess: () => void; onSuccess: () => void;
} }
export function RoleForm({ open, onOpenChange, role, onSuccess }: RoleFormProps) { export function RoleForm(props: RoleFormProps) {
const [name, setName] = useState(''); return (
const [description, setDescription] = useState(''); <RoleFormBody key={props.open ? `open:${props.role?.id ?? 'new'}` : 'closed'} {...props} />
const [permissions, setPermissions] = useState<Record<string, Record<string, boolean>>>(
structuredClone(DEFAULT_PERMISSIONS),
); );
}
function RoleFormBody({ open, onOpenChange, role, onSuccess }: RoleFormProps) {
// Merge role permissions over defaults to fill any missing keys.
const initialPermissions = (() => {
const merged = structuredClone(DEFAULT_PERMISSIONS);
if (role) {
for (const [group, actions] of Object.entries(role.permissions)) {
if (merged[group]) {
for (const [action, value] of Object.entries(actions as Record<string, boolean>)) {
merged[group]![action] = value;
}
}
}
}
return merged;
})();
const [name, setName] = useState(role?.name ?? '');
const [description, setDescription] = useState(role?.description ?? '');
const [permissions, setPermissions] =
useState<Record<string, Record<string, boolean>>>(initialPermissions);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isEdit = !!role; const isEdit = !!role;
useEffect(() => {
if (open) {
if (role) {
setName(role.name);
setDescription(role.description ?? '');
// Merge role permissions over defaults to fill any missing keys
const merged = structuredClone(DEFAULT_PERMISSIONS);
for (const [group, actions] of Object.entries(role.permissions)) {
if (merged[group]) {
for (const [action, value] of Object.entries(actions as Record<string, boolean>)) {
merged[group]![action] = value;
}
}
}
setPermissions(merged);
} else {
setName('');
setDescription('');
setPermissions(structuredClone(DEFAULT_PERMISSIONS));
}
setError(null);
}
}, [open, role]);
function togglePermission(group: string, action: string) { function togglePermission(group: string, action: string) {
setPermissions((prev) => { setPermissions((prev) => {
const next = structuredClone(prev); const next = structuredClone(prev);

View File

@@ -127,6 +127,8 @@ export function SalesEmailConfigCard() {
} }
useEffect(() => { useEffect(() => {
// Initial load on mount — canonical fetch-once pattern.
// eslint-disable-next-line react-hooks/set-state-in-effect
void refresh(); void refresh();
}, []); }, []);

View File

@@ -258,6 +258,8 @@ export function SettingsManager() {
}, []); }, []);
useEffect(() => { useEffect(() => {
// Initial settings load on mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
void fetchSettings(); void fetchSettings();
}, [fetchSettings]); }, [fetchSettings]);

View File

@@ -107,6 +107,8 @@ export function SettingsFormCard({ title, description, fields, extra }: Settings
}, []); }, []);
useEffect(() => { useEffect(() => {
// Initial load — fetchValues internally setStates loading + values.
// eslint-disable-next-line react-hooks/set-state-in-effect
void fetchValues(); void fetchValues();
}, [fetchValues]); }, [fetchValues]);

View File

@@ -28,6 +28,7 @@ import {
SettingsFormCard, SettingsFormCard,
type SettingFieldDef, type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card'; } from '@/components/admin/shared/settings-form-card';
import { WarningCallout } from '@/components/ui/warning-callout';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
@@ -360,12 +361,12 @@ export function StorageAdminPanel() {
</div> </div>
)} )}
{confirmMode === 'switch-only' && ( {confirmMode === 'switch-only' && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900"> <WarningCallout>
<strong>Warning:</strong> {s.fileCount} existing file {s.fileCount} existing file
{s.fileCount === 1 ? '' : 's'} on <code className="text-xs">{s.backend}</code> will {s.fileCount === 1 ? '' : 's'} on <code className="text-xs">{s.backend}</code> will
not be reachable from the CRM after the switch unless you migrate them later. This is not be reachable from the CRM after the switch unless you migrate them later. This is
rarely the right choice prefer Switch + migrate. rarely the right choice prefer Switch + migrate.
</div> </WarningCallout>
)} )}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}> <Button variant="outline" onClick={() => setConfirmOpen(false)}>

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { formatErrorBanner } from '@/lib/api/toast-error'; import { formatErrorBanner } from '@/lib/api/toast-error';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -53,75 +54,49 @@ interface UserFormProps {
onSuccess: () => void; onSuccess: () => void;
} }
export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps) { export function UserForm(props: UserFormProps) {
const [roles, setRoles] = useState<Role[]>([]); return (
const [firstName, setFirstName] = useState(''); <UserFormBody key={props.open ? `open:${props.user?.userId ?? 'new'}` : 'closed'} {...props} />
const [lastName, setLastName] = useState(''); );
const [email, setEmail] = useState(''); }
const [originalEmail, setOriginalEmail] = useState('');
function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
// Derive initial first/last names from the user payload.
const initialNames = (() => {
if (!user) return { first: '', last: '' };
if (user.firstName || user.lastName) {
return { first: user.firstName ?? '', last: user.lastName ?? '' };
}
const source = user.fullName ?? user.displayName;
const parts = source.split(/\s+/);
return { first: parts[0] ?? '', last: parts.slice(1).join(' ') };
})();
// useQuery replaces the prior useEffect(fetch+setRoles) pattern.
const rolesQuery = useQuery<{ data: Role[] }>({
queryKey: ['admin', 'roles'],
queryFn: () => apiFetch('/api/v1/admin/roles'),
enabled: open,
});
const roles = rolesQuery.data?.data ?? [];
const [firstName, setFirstName] = useState(initialNames.first);
const [lastName, setLastName] = useState(initialNames.last);
const [email, setEmail] = useState(user?.email ?? '');
const [originalEmail] = useState(user?.email ?? '');
const [emailConfirmOpen, setEmailConfirmOpen] = useState(false); const [emailConfirmOpen, setEmailConfirmOpen] = useState(false);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState(''); const [displayName, setDisplayName] = useState(user?.displayName ?? '');
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(null); const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
const [roleId, setRoleId] = useState(''); user?.phone ? { e164: user.phone, country: 'US' } : null,
const [isActive, setIsActive] = useState(true); );
const [residentialAccess, setResidentialAccess] = useState(false); const [roleId, setRoleId] = useState(user?.role.id ?? '');
const [isActive, setIsActive] = useState(user?.isActive ?? true);
const [residentialAccess, setResidentialAccess] = useState(user?.residentialAccess ?? false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isEdit = !!user; const isEdit = !!user;
const fullName = `${firstName} ${lastName}`.trim(); const fullName = `${firstName} ${lastName}`.trim();
useEffect(() => {
if (open) {
void apiFetch<{ data: Role[] }>('/api/v1/admin/roles').then((res) => setRoles(res.data));
}
}, [open]);
useEffect(() => {
if (open) {
if (user) {
// Prefer canonical first/last from the API; fall back to a best-
// effort split of displayName for older records that pre-date the
// first_name/last_name columns.
const first = user.firstName ?? '';
const last = user.lastName ?? '';
if (first || last) {
setFirstName(first);
setLastName(last);
} else if (user.fullName) {
const parts = user.fullName.split(/\s+/);
setFirstName(parts[0] ?? '');
setLastName(parts.slice(1).join(' '));
} else {
const parts = user.displayName.split(/\s+/);
setFirstName(parts[0] ?? '');
setLastName(parts.slice(1).join(' '));
}
setEmail(user.email);
setOriginalEmail(user.email);
setDisplayName(user.displayName);
setPhoneValue(user.phone ? { e164: user.phone, country: 'US' } : null);
setRoleId(user.role.id);
setIsActive(user.isActive);
setResidentialAccess(user.residentialAccess ?? false);
setPassword('');
} else {
setFirstName('');
setLastName('');
setEmail('');
setOriginalEmail('');
setDisplayName('');
setPhoneValue(null);
setRoleId('');
setIsActive(true);
setResidentialAccess(false);
setPassword('');
}
setError(null);
}
}, [open, user]);
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
// Admin email change for an existing user goes through a confirmation // Admin email change for an existing user goes through a confirmation

View File

@@ -12,6 +12,8 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { formatEnum } from '@/lib/constants';
import { WarningCallout } from '@/components/ui/warning-callout';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** /**
@@ -103,7 +105,7 @@ const PERMISSION_LEAVES: Record<string, string[]> = {
}; };
function formatAction(action: string): string { function formatAction(action: string): string {
return action.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); return formatEnum(action);
} }
type Overrides = Record<string, Record<string, boolean>>; type Overrides = Record<string, Record<string, boolean>>;
@@ -223,13 +225,13 @@ export function UserPermissionMatrix({ userId }: UserPermissionMatrixProps) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900"> <WarningCallout icon={false}>
<p> <span className="text-xs">
Permission overrides save <strong>on the button below</strong>, separately from the Permission overrides save <strong>on the button below</strong>, separately from the
Profile &amp; role tab. Switching tabs or closing the drawer without clicking Profile &amp; role tab. Switching tabs or closing the drawer without clicking{' '}
<strong> Save overrides</strong> drops your changes. <strong>Save overrides</strong> drops your changes.
</p> </span>
</div> </WarningCallout>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Each toggle defaults to <strong>Inherit</strong> (role + port override decide). Switch to Each toggle defaults to <strong>Inherit</strong> (role + port override decide). Switch to
<strong> Grant</strong> or <strong>Deny</strong> to force the value for this user only. <strong> Grant</strong> or <strong>Deny</strong> to force the value for this user only.

View File

@@ -84,6 +84,8 @@ export function VocabulariesManager() {
}, []); }, []);
useEffect(() => { useEffect(() => {
// Initial vocabularies load on mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
void fetchAll(); void fetchAll();
}, [fetchAll]); }, [fetchAll]);

View File

@@ -44,16 +44,17 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
useEffect(() => { useEffect(() => {
if (searchParams.get('edit') === 'true') { if (searchParams.get('edit') === 'true') {
// setState in effect is the right shape here — the URL is an
// external store and the trigger is a query-param change, not a
// prop in the React tree.
// eslint-disable-next-line react-hooks/set-state-in-effect
setEditOpen(true); setEditOpen(true);
// Strip the param without adding a history entry // Strip the param without adding a history entry
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.delete('edit'); params.delete('edit');
const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname; const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname;
// typedRoutes can't statically validate this dynamic path; cast is safe
// because we're always replacing within the same route segment.
router.replace(newUrl as never); router.replace(newUrl as never);
} }
// Only run once on mount / when searchParams changes
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]); }, [searchParams]);

View File

@@ -222,11 +222,13 @@ function OverviewTab({ berth }: { berth: BerthData }) {
const patch = useBerthPatch(berth.id); const patch = useBerthPatch(berth.id);
// User-selected display unit for dimensions. Persisted in localStorage // User-selected display unit for dimensions. Persisted in localStorage
// so reps' preferred unit sticks across navigations + sessions. // so reps' preferred unit sticks across navigations + sessions.
const [units, setUnits] = useState<'ft' | 'm'>('ft'); // Lazy initializer reads localStorage on first render — avoids the
useEffect(() => { // mount-effect-setState shape the compiler flags.
const stored = localStorage.getItem('berth-overview-units'); const [units, setUnits] = useState<'ft' | 'm'>(() => {
if (stored === 'ft' || stored === 'm') setUnits(stored); if (typeof window === 'undefined') return 'ft';
}, []); const stored = window.localStorage.getItem('berth-overview-units');
return stored === 'ft' || stored === 'm' ? stored : 'ft';
});
useEffect(() => { useEffect(() => {
localStorage.setItem('berth-overview-units', units); localStorage.setItem('berth-overview-units', units);
}, [units]); }, [units]);

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from 'lucide-react'; import { AlertTriangle, ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -16,6 +16,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { WarningCallout } from '@/components/ui/warning-callout';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
interface PreflightItem { interface PreflightItem {
@@ -36,7 +37,19 @@ interface Props {
type Stage = 'preflight' | 'reasons' | 'confirm'; type Stage = 'preflight' | 'reasons' | 'confirm';
export function BulkArchiveWizard({ open, onOpenChange, clientIds, onSuccess }: Props) { export function BulkArchiveWizard(props: Props) {
// Key-based remount: body keyed on open + clientIds so its useState
// initializers re-run each time the wizard opens fresh. Replaces the
// useEffect(setState, [open]) reset the Compiler flagged.
return (
<BulkArchiveWizardBody
key={props.open ? `open:${props.clientIds.join(',')}` : 'closed'}
{...props}
/>
);
}
function BulkArchiveWizardBody({ open, onOpenChange, clientIds, onSuccess }: Props) {
const qc = useQueryClient(); const qc = useQueryClient();
const [stage, setStage] = useState<Stage>('preflight'); const [stage, setStage] = useState<Stage>('preflight');
const [reasons, setReasons] = useState<Record<string, string>>({}); const [reasons, setReasons] = useState<Record<string, string>>({});
@@ -52,14 +65,6 @@ export function BulkArchiveWizard({ open, onOpenChange, clientIds, onSuccess }:
enabled: open && clientIds.length > 0, enabled: open && clientIds.length > 0,
}); });
useEffect(() => {
if (open) {
setStage('preflight');
setReasons({});
setCarouselIndex(0);
}
}, [open]);
const items = preflight.data ?? []; const items = preflight.data ?? [];
const blocked = useMemo(() => items.filter((i) => i.blockers.length > 0), [items]); const blocked = useMemo(() => items.filter((i) => i.blockers.length > 0), [items]);
const highStakes = useMemo( const highStakes = useMemo(
@@ -192,15 +197,17 @@ export function BulkArchiveWizard({ open, onOpenChange, clientIds, onSuccess }:
))} ))}
</span> </span>
</div> </div>
<div className="rounded-md border border-amber-300 bg-amber-50 p-3"> <WarningCallout
<div className="flex items-center gap-2 mb-1.5"> title={
<AlertTriangle className="h-4 w-4 text-amber-700" /> <span className="flex items-center gap-2">
<span className="font-medium text-amber-900">{currentHighStakes.fullName}</span> <span>{currentHighStakes.fullName}</span>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{currentHighStakes.highStakesStage} {currentHighStakes.highStakesStage}
</Badge> </Badge>
</div> </span>
<div className="text-xs text-amber-900"> }
>
<span className="text-xs">
{currentHighStakes.summary.berths > 0 {currentHighStakes.summary.berths > 0
? `${currentHighStakes.summary.berths} berth(s), ` ? `${currentHighStakes.summary.berths} berth(s), `
: ''} : ''}
@@ -210,8 +217,8 @@ export function BulkArchiveWizard({ open, onOpenChange, clientIds, onSuccess }:
{currentHighStakes.summary.reservations > 0 {currentHighStakes.summary.reservations > 0
? `${currentHighStakes.summary.reservations} reservation(s)` ? `${currentHighStakes.summary.reservations} reservation(s)`
: ''} : ''}
</div> </span>
</div> </WarningCallout>
<Textarea <Textarea
value={reasons[currentHighStakes.clientId] ?? ''} value={reasons[currentHighStakes.clientId] ?? ''}
onChange={(e) => onChange={(e) =>

View File

@@ -9,6 +9,7 @@ import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import type { FileRow } from '@/components/files/file-grid'; import type { FileRow } from '@/components/files/file-grid';
@@ -19,6 +20,7 @@ interface ClientFilesTabProps {
export function ClientFilesTab({ clientId }: ClientFilesTabProps) { export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [previewFile, setPreviewFile] = useState<FileRow | null>(null); const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const { confirm, dialog: confirmDialog } = useConfirmation();
const { data, isLoading } = usePaginatedQuery<FileRow>({ const { data, isLoading } = usePaginatedQuery<FileRow>({
queryKey: ['files', { clientId }], queryKey: ['files', { clientId }],
@@ -47,7 +49,12 @@ export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
}; };
const handleDelete = async (file: FileRow) => { const handleDelete = async (file: FileRow) => {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return; const ok = await confirm({
title: 'Delete file',
description: `Delete "${file.filename}"? This cannot be undone.`,
confirmLabel: 'Delete',
});
if (!ok) return;
try { try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' }); await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: ['files', { clientId }] }); queryClient.invalidateQueries({ queryKey: ['files', { clientId }] });
@@ -83,6 +90,7 @@ export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
fileName={previewFile?.filename} fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined} mimeType={previewFile?.mimeType ?? undefined}
/> />
{confirmDialog}
</div> </div>
); );
} }

View File

@@ -11,7 +11,7 @@ import { ArrowRight, CheckCircle2, ChevronRight, Circle, Plus } from 'lucide-rea
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/shared/empty-state'; import { EmptyState } from '@/components/shared/empty-state';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/shared/drawer'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants'; import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -46,10 +46,10 @@ function InterestRowItem({
const yachtLabel = interest.yachtName ?? null; const yachtLabel = interest.yachtName ?? null;
return ( return (
// Tap opens a bottom-sheet preview drawer rather than navigating to the // Tap opens a right-side Sheet preview rather than navigating to the
// full interest page. The drawer covers ~80% of mobile interactions // full interest page. The sheet covers ~80% of interactions ("what
// ("what stage is this at, when did we last touch it"). For deeper // stage is this at, when did we last touch it"). For deeper edits
// edits the drawer has an "Open full page" CTA. // the sheet has an "Open full page" CTA.
<button <button
type="button" type="button"
onClick={() => onOpen(interest)} onClick={() => onOpen(interest)}
@@ -214,17 +214,17 @@ function InterestPreviewDrawer({
const notesPreview = fullDetail?.notes?.trim() || null; const notesPreview = fullDetail?.notes?.trim() || null;
return ( return (
<Drawer <Sheet
open={open} open={open}
onOpenChange={(next) => { onOpenChange={(next) => {
if (!next) onClose(); if (!next) onClose();
}} }}
> >
<DrawerContent className="max-h-[85vh]"> <SheetContent side="right" className="w-full overflow-y-auto sm:max-w-md">
<DrawerHeader> <SheetHeader>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<DrawerTitle className="truncate">{berthLabel}</DrawerTitle> <SheetTitle className="truncate text-left">{berthLabel}</SheetTitle>
{yachtLabel ? ( {yachtLabel ? (
<p className="mt-0.5 truncate text-sm text-muted-foreground">{yachtLabel}</p> <p className="mt-0.5 truncate text-sm text-muted-foreground">{yachtLabel}</p>
) : null} ) : null}
@@ -240,9 +240,9 @@ function InterestPreviewDrawer({
</span> </span>
) : null} ) : null}
</div> </div>
</DrawerHeader> </SheetHeader>
<div className="space-y-5 overflow-y-auto px-4 pb-4"> <div className="mt-5 space-y-5">
{/* Pipeline-stepper segmented bar - the same primitive used on the {/* Pipeline-stepper segmented bar - the same primitive used on the
row card, so the at-a-glance progress hint is consistent row card, so the at-a-glance progress hint is consistent
across surfaces. */} across surfaces. */}
@@ -357,8 +357,8 @@ function InterestPreviewDrawer({
</Link> </Link>
</Button> </Button>
</div> </div>
</DrawerContent> </SheetContent>
</Drawer> </Sheet>
); );
} }

View File

@@ -26,6 +26,7 @@ import {
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlinePhoneField } from '@/components/shared/inline-phone-field'; import { InlinePhoneField } from '@/components/shared/inline-phone-field';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -57,6 +58,7 @@ const CHANNEL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
export function ContactsEditor({ clientId, contacts }: { clientId: string; contacts: Contact[] }) { export function ContactsEditor({ clientId, contacts }: { clientId: string; contacts: Contact[] }) {
const qc = useQueryClient(); const qc = useQueryClient();
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const { confirm, dialog: confirmDialog } = useConfirmation();
function invalidate() { function invalidate() {
qc.invalidateQueries({ queryKey: ['clients', clientId] }); qc.invalidateQueries({ queryKey: ['clients', clientId] });
@@ -112,7 +114,12 @@ export function ContactsEditor({ clientId, contacts }: { clientId: string; conta
contact={c} contact={c}
onUpdate={(patch) => updateMutation.mutateAsync({ contactId: c.id, patch })} onUpdate={(patch) => updateMutation.mutateAsync({ contactId: c.id, patch })}
onRemove={async () => { onRemove={async () => {
if (!confirm('Remove this contact?')) return; const ok = await confirm({
title: 'Remove contact',
description: 'Remove this contact?',
confirmLabel: 'Remove',
});
if (!ok) return;
await removeMutation.mutateAsync(c.id); await removeMutation.mutateAsync(c.id);
}} }}
/> />
@@ -138,6 +145,7 @@ export function ContactsEditor({ clientId, contacts }: { clientId: string; conta
Add contact Add contact
</Button> </Button>
)} )}
{confirmDialog}
</div> </div>
); );
} }

View File

@@ -16,6 +16,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { WarningCallout } from '@/components/ui/warning-callout';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
interface Props { interface Props {
@@ -108,22 +109,19 @@ function HardDeleteDialogBody({ onOpenChange, clientId, clientName, onDeleted }:
Permanent deletion is reserved for archived clients only. We&rsquo;ll email a 4-digit Permanent deletion is reserved for archived clients only. We&rsquo;ll email a 4-digit
confirmation code to your account address. The code expires in 10 minutes. confirmation code to your account address. The code expires in 10 minutes.
</p> </p>
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900"> <WarningCallout title="What gets deleted">
<p className="font-medium flex items-center gap-2">
<AlertTriangle className="h-4 w-4" /> What gets deleted
</p>
<ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5"> <ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5">
<li>Client record + addresses, contacts, notes, tags</li> <li>Client record + addresses, contacts, notes, tags</li>
<li>Portal user account + GDPR consent records</li> <li>Portal user account + GDPR consent records</li>
<li>All pipeline interests + reservations for this client</li> <li>All pipeline interests + reservations for this client</li>
</ul> </ul>
<p className="font-medium mt-2 flex items-center gap-2">What is preserved</p> <p className="font-medium mt-2">What is preserved</p>
<ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5"> <ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5">
<li>Signed documents (detached from client, kept for legal history)</li> <li>Signed documents (detached from client, kept for legal history)</li>
<li>Email threads, files, reminders (detached)</li> <li>Email threads, files, reminders (detached)</li>
<li>Audit log entries</li> <li>Audit log entries</li>
</ul> </ul>
</div> </WarningCallout>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Anchor, FileText, Loader2, Receipt, Ship, Users } from 'lucide-react'; import { AlertTriangle, Anchor, FileText, Loader2, Receipt, Ship, Users } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -95,50 +95,96 @@ interface Props {
onSuccess?: () => void; onSuccess?: () => void;
} }
export function SmartArchiveDialog({ open, onOpenChange, clientId, clientName, onSuccess }: Props) { export function SmartArchiveDialog(props: Props) {
const qc = useQueryClient(); // Key-based remount: body keyed on open + clientId; once the dossier
// loads, an inner key forces the decision-defaults to seed cleanly.
return (
<SmartArchiveDialogShell key={props.open ? `open:${props.clientId}` : 'closed'} {...props} />
);
}
function SmartArchiveDialogShell({ open, onOpenChange, clientId, clientName, onSuccess }: Props) {
const qc = useQueryClient();
const dossierQuery = useQuery({ const dossierQuery = useQuery({
queryKey: ['client-archive-dossier', clientId], queryKey: ['client-archive-dossier', clientId],
queryFn: () => queryFn: () =>
apiFetch<{ data: ArchiveDossier }>(`/api/v1/clients/${clientId}/archive-dossier`), apiFetch<{ data: ArchiveDossier }>(`/api/v1/clients/${clientId}/archive-dossier`),
enabled: open, enabled: open,
}); });
const dossier = dossierQuery.data?.data; const dossier = dossierQuery.data?.data;
// While the dossier is loading the body's useState initializers can't
// derive defaults, so we delay-key the body so it mounts ONCE with the
// right seed when the data arrives. Replaces the prior
// useEffect(setState, [dossier]) sync that the Compiler flagged.
return (
<SmartArchiveDialogBody
key={dossier ? 'loaded' : 'loading'}
open={open}
onOpenChange={onOpenChange}
clientId={clientId}
clientName={clientName}
onSuccess={onSuccess}
dossier={dossier ?? null}
isLoading={dossierQuery.isLoading}
error={dossierQuery.error}
qc={qc}
/>
);
}
function SmartArchiveDialogBody({
open,
onOpenChange,
clientId,
clientName,
onSuccess,
dossier,
isLoading,
error,
qc,
}: Props & {
dossier: ArchiveDossier | null;
isLoading: boolean;
error: unknown;
qc: ReturnType<typeof useQueryClient>;
}) {
// ─── Local decision state ──────────────────────────────────────────────── // ─── Local decision state ────────────────────────────────────────────────
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const [acknowledged, setAcknowledged] = useState(false); const [acknowledged, setAcknowledged] = useState(false);
const [berthDecisions, setBerthDecisions] = useState<Record<string, BerthAction>>({}); const [berthDecisions, setBerthDecisions] = useState<Record<string, BerthAction>>(() =>
const [yachtDecisions, setYachtDecisions] = useState<Record<string, YachtAction>>({}); dossier
? Object.fromEntries(
dossier.berths.map((berth) => [
berth.berthId,
berth.status === 'sold' ? ('retain' as BerthAction) : ('release' as BerthAction),
]),
)
: {},
);
const [yachtDecisions, setYachtDecisions] = useState<Record<string, YachtAction>>(() =>
dossier
? Object.fromEntries(dossier.yachts.map((y) => [y.yachtId, 'retain' as YachtAction]))
: {},
);
const [reservationDecisions, setReservationDecisions] = useState< const [reservationDecisions, setReservationDecisions] = useState<
Record<string, ReservationAction> Record<string, ReservationAction>
>({}); >(() =>
const [invoiceDecisions, setInvoiceDecisions] = useState<Record<string, InvoiceAction>>({}); dossier
const [documentDecisions, setDocumentDecisions] = useState<Record<string, DocumentAction>>({}); ? Object.fromEntries(
dossier.reservations.map((r) => [r.reservationId, 'cancel' as ReservationAction]),
// Reset state when the dialog opens / closes / dossier loads. )
useEffect(() => { : {},
if (!open || !dossier) return; );
setReason(''); const [invoiceDecisions, setInvoiceDecisions] = useState<Record<string, InvoiceAction>>(() =>
setAcknowledged(false); dossier
// Sensible defaults: release all berths, retain all yachts, cancel ? Object.fromEntries(dossier.invoices.map((i) => [i.invoiceId, 'leave' as InvoiceAction]))
// active reservations, leave invoices, leave documents alone. : {},
const b: Record<string, BerthAction> = {}; );
for (const berth of dossier.berths) { const [documentDecisions, setDocumentDecisions] = useState<Record<string, DocumentAction>>(() =>
// Sold berths can't be released; default to retain. dossier
b[berth.berthId] = berth.status === 'sold' ? 'retain' : 'release'; ? Object.fromEntries(dossier.documents.map((d) => [d.documentId, 'leave' as DocumentAction]))
} : {},
setBerthDecisions(b); );
setYachtDecisions(Object.fromEntries(dossier.yachts.map((y) => [y.yachtId, 'retain'])));
setReservationDecisions(
Object.fromEntries(dossier.reservations.map((r) => [r.reservationId, 'cancel'])),
);
setInvoiceDecisions(Object.fromEntries(dossier.invoices.map((i) => [i.invoiceId, 'leave'])));
setDocumentDecisions(Object.fromEntries(dossier.documents.map((d) => [d.documentId, 'leave'])));
}, [open, dossier]);
const hasSignedDocs = useMemo( const hasSignedDocs = useMemo(
() => () =>
dossier?.documents.some((d) => d.status === 'completed' || d.status === 'signed') ?? false, dossier?.documents.some((d) => d.status === 'completed' || d.status === 'signed') ?? false,
@@ -235,15 +281,14 @@ export function SmartArchiveDialog({ open, onOpenChange, clientId, clientName, o
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{dossierQuery.isLoading ? ( {isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground"> <div className="py-8 text-center text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" /> <Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
Loading dossier Loading dossier
</div> </div>
) : dossierQuery.error || !dossier ? ( ) : error || !dossier ? (
<div className="py-8 text-center text-sm text-red-600"> <div className="py-8 text-center text-sm text-red-600">
Failed to load dossier:{' '} Failed to load dossier: {error instanceof Error ? error.message : 'unknown error'}
{dossierQuery.error instanceof Error ? dossierQuery.error.message : 'unknown error'}
</div> </div>
) : ( ) : (
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1"> <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
AlertTriangle, AlertTriangle,
@@ -59,7 +59,16 @@ function iconFor(kind: string) {
return <Wrench className="h-3 w-3" />; return <Wrench className="h-3 w-3" />;
} }
export function SmartRestoreDialog({ open, onOpenChange, clientId, clientName, onSuccess }: Props) { export function SmartRestoreDialog(props: Props) {
// Key-based remount: the body is keyed on open + clientId so its
// useState({}) initializer runs fresh each open. Replaces the prior
// useEffect(setSelected, [open, dossier]) reset.
return (
<SmartRestoreDialogBody key={props.open ? `open:${props.clientId}` : 'closed'} {...props} />
);
}
function SmartRestoreDialogBody({ open, onOpenChange, clientId, clientName, onSuccess }: Props) {
const qc = useQueryClient(); const qc = useQueryClient();
const dossierQuery = useQuery({ const dossierQuery = useQuery({
@@ -73,11 +82,6 @@ export function SmartRestoreDialog({ open, onOpenChange, clientId, clientName, o
const [selected, setSelected] = useState<Record<string, boolean>>({}); const [selected, setSelected] = useState<Record<string, boolean>>({});
useEffect(() => {
if (!open || !dossier) return;
setSelected({});
}, [open, dossier]);
const restoreMutation = useMutation({ const restoreMutation = useMutation({
mutationFn: () => { mutationFn: () => {
const applyReversals = Object.entries(selected) const applyReversals = Object.entries(selected)

View File

@@ -9,6 +9,7 @@ import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import type { FileRow } from '@/components/files/file-grid'; import type { FileRow } from '@/components/files/file-grid';
@@ -19,6 +20,7 @@ interface CompanyFilesTabProps {
export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) { export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [previewFile, setPreviewFile] = useState<FileRow | null>(null); const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const { confirm, dialog: confirmDialog } = useConfirmation();
const { data, isLoading } = usePaginatedQuery<FileRow>({ const { data, isLoading } = usePaginatedQuery<FileRow>({
queryKey: ['files', { companyId }], queryKey: ['files', { companyId }],
@@ -47,7 +49,12 @@ export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) {
}; };
const handleDelete = async (file: FileRow) => { const handleDelete = async (file: FileRow) => {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return; const ok = await confirm({
title: 'Delete file',
description: `Delete "${file.filename}"? This cannot be undone.`,
confirmLabel: 'Delete',
});
if (!ok) return;
try { try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' }); await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: ['files', { companyId }] }); queryClient.invalidateQueries({ queryKey: ['files', { companyId }] });
@@ -83,6 +90,7 @@ export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) {
fileName={previewFile?.filename} fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined} mimeType={previewFile?.mimeType ?? undefined}
/> />
{confirmDialog}
</div> </div>
); );
} }

View File

@@ -37,12 +37,14 @@ import { useCreateFromUrl } from '@/hooks/use-create-from-url';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useTablePreferences } from '@/hooks/use-table-preferences'; import { useTablePreferences } from '@/hooks/use-table-preferences';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
export function CompanyList() { export function CompanyList() {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true)); useCreateFromUrl(() => setCreateOpen(true));
@@ -195,15 +197,14 @@ export function CompanyList() {
label: 'Archive', label: 'Archive',
icon: Archive, icon: Archive,
variant: 'destructive', variant: 'destructive',
onClick: (ids) => { onClick: async (ids) => {
if (ids.length === 0) return; if (ids.length === 0) return;
if ( const ok = await confirm({
!window.confirm( title: `Archive ${ids.length} compan${ids.length === 1 ? 'y' : 'ies'}`,
`Archive ${ids.length} compan${ids.length === 1 ? 'y' : 'ies'}? This can be undone from the archived list.`, description: 'This can be undone from the archived list.',
) confirmLabel: 'Archive',
) { });
return; if (!ok) return;
}
bulkMutation.mutate({ action: 'archive', ids }); bulkMutation.mutate({ action: 'archive', ids });
}, },
}, },
@@ -303,6 +304,7 @@ export function CompanyList() {
onConfirm={() => archiveCompany && archiveMutation.mutate(archiveCompany.id)} onConfirm={() => archiveCompany && archiveMutation.mutate(archiveCompany.id)}
isLoading={archiveMutation.isPending} isLoading={archiveMutation.isPending}
/> />
{confirmDialog}
</div> </div>
); );
} }

View File

@@ -107,6 +107,9 @@ export function DashboardShell({
// local-time-aware phrasing once the component has mounted. // local-time-aware phrasing once the component has mounted.
const [clientGreeting, setClientGreeting] = useState<string | null>(null); const [clientGreeting, setClientGreeting] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
// setState here is intentional — we delay the time-aware greeting
// until after hydration to avoid SSR/client clock mismatch.
// eslint-disable-next-line react-hooks/set-state-in-effect
setClientGreeting(timeOfDayGreeting()); setClientGreeting(timeOfDayGreeting());
// Re-evaluate hourly so a rep who leaves the dashboard open through a // Re-evaluate hourly so a rep who leaves the dashboard open through a
// boundary (5am, noon, 6pm) doesn't keep stale text on screen. // boundary (5am, noon, 6pm) doesn't keep stale text on screen.

View File

@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { formatEnum } from '@/lib/constants';
interface SourceRow { interface SourceRow {
source: string; source: string;
@@ -57,7 +58,7 @@ export function SourceConversionChart() {
<ul className="space-y-3"> <ul className="space-y-3">
{rows.map((r) => { {rows.map((r) => {
const pct = Math.round(r.conversionRate * 100); const pct = Math.round(r.conversionRate * 100);
const label = r.source.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); const label = formatEnum(r.source);
return ( return (
<li key={r.source} className="space-y-1"> <li key={r.source} className="space-y-1">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">

View File

@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
@@ -93,6 +94,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isCancelling, setIsCancelling] = useState(false); const [isCancelling, setIsCancelling] = useState(false);
const { confirm, dialog: confirmDialog } = useConfirmation();
const { data, isLoading, error } = useQuery<DetailResponse>({ const { data, isLoading, error } = useQuery<DetailResponse>({
queryKey: ['document-detail', documentId], queryKey: ['document-detail', documentId],
@@ -157,8 +159,12 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
}; };
const handleCancel = async () => { const handleCancel = async () => {
if (!confirm('Cancel this document? This voids the signing envelope and cannot be undone.')) const ok = await confirm({
return; title: 'Cancel document',
description: 'Cancel this document? This voids the signing envelope and cannot be undone.',
confirmLabel: 'Cancel document',
});
if (!ok) return;
setIsCancelling(true); setIsCancelling(true);
try { try {
await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' }); await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' });
@@ -395,6 +401,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
</section> </section>
</div> </div>
</div> </div>
{confirmDialog}
</div> </div>
); );
} }

View File

@@ -14,6 +14,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { MoveToFolderDialog } from './move-to-folder-dialog'; import { MoveToFolderDialog } from './move-to-folder-dialog';
@@ -121,6 +122,7 @@ function DocRow({ doc, onDelete, onSend }: DocRowProps) {
export function DocumentList({ interestId, clientId, emptyState }: DocumentListProps) { export function DocumentList({ interestId, clientId, emptyState }: DocumentListProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (interestId) queryParams.set('interestId', interestId); if (interestId) queryParams.set('interestId', interestId);
@@ -133,7 +135,12 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
}); });
const handleDelete = async (doc: DocumentRow) => { const handleDelete = async (doc: DocumentRow) => {
if (!confirm(`Delete "${doc.title}"? This cannot be undone.`)) return; const ok = await confirm({
title: 'Delete document',
description: `Delete "${doc.title}"? This cannot be undone.`,
confirmLabel: 'Delete',
});
if (!ok) return;
try { try {
await apiFetch(`/api/v1/documents/${doc.id}`, { method: 'DELETE' }); await apiFetch(`/api/v1/documents/${doc.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: ['documents', { interestId, clientId }] }); queryClient.invalidateQueries({ queryKey: ['documents', { interestId, clientId }] });
@@ -181,6 +188,7 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
))} ))}
</tbody> </tbody>
</table> </table>
{confirmDialog}
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Check, FolderInput } from 'lucide-react'; import { Check, FolderInput } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -35,85 +35,94 @@ interface MoveToFolderDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export function MoveToFolderDialog({ export function MoveToFolderDialog(props: MoveToFolderDialogProps) {
// Key-based remount: the inner body is keyed on `open + currentFolderId`
// so its `useState` initializer re-runs each time the dialog is re-
// opened, replacing the prior `useEffect(setPickedId)` pattern that the
// React Compiler flagged as set-state-in-effect.
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className="sm:max-w-md">
{props.open ? (
<DialogBody
key={`${props.documentId}:${props.currentFolderId ?? '__root__'}`}
{...props}
/>
) : null}
</DialogContent>
</Dialog>
);
}
function DialogBody({
documentId, documentId,
documentTitle, documentTitle,
currentFolderId, currentFolderId,
open,
onOpenChange, onOpenChange,
}: MoveToFolderDialogProps) { }: MoveToFolderDialogProps) {
const { data: tree = [] } = useDocumentFolders(); const { data: tree = [] } = useDocumentFolders();
const move = useMoveDocument(); const move = useMoveDocument();
const [pickedId, setPickedId] = useState<string | null>(currentFolderId); const [pickedId, setPickedId] = useState<string | null>(currentFolderId);
useEffect(() => {
if (open) setPickedId(currentFolderId);
}, [open, currentFolderId]);
const paths = useMemo(() => buildFolderPaths(tree), [tree]); const paths = useMemo(() => buildFolderPaths(tree), [tree]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <>
<DialogContent className="sm:max-w-md"> <DialogHeader>
<DialogHeader> <DialogTitle>Move &ldquo;{documentTitle}&rdquo;</DialogTitle>
<DialogTitle>Move &ldquo;{documentTitle}&rdquo;</DialogTitle> </DialogHeader>
</DialogHeader> <Command>
<Command> <CommandInput placeholder="Search folders…" />
<CommandInput placeholder="Search folders…" /> <CommandList>
<CommandList> <CommandEmpty>No folders match.</CommandEmpty>
<CommandEmpty>No folders match.</CommandEmpty> <CommandGroup heading="Special">
<CommandGroup heading="Special"> <CommandItem
<CommandItem value="Root (no folder)"
value="Root (no folder)" onSelect={() => setPickedId(null)}
onSelect={() => setPickedId(null)} className="flex items-center gap-2"
className="flex items-center gap-2" >
> <Check className={pickedId === null ? 'h-4 w-4 opacity-100' : 'h-4 w-4 opacity-0'} />
<Check Root (no folder)
className={pickedId === null ? 'h-4 w-4 opacity-100' : 'h-4 w-4 opacity-0'} </CommandItem>
/> </CommandGroup>
Root (no folder) {paths.length > 0 ? (
</CommandItem> <CommandGroup heading="Folders">
{paths.map((p) => (
<CommandItem
key={p.id}
value={p.path}
onSelect={() => setPickedId(p.id)}
className="flex items-center gap-2"
>
<Check
className={pickedId === p.id ? 'h-4 w-4 opacity-100' : 'h-4 w-4 opacity-0'}
/>
<span className="truncate">{p.path}</span>
</CommandItem>
))}
</CommandGroup> </CommandGroup>
{paths.length > 0 ? ( ) : null}
<CommandGroup heading="Folders"> </CommandList>
{paths.map((p) => ( </Command>
<CommandItem <DialogFooter>
key={p.id} <Button variant="outline" onClick={() => onOpenChange(false)}>
value={p.path} Cancel
onSelect={() => setPickedId(p.id)} </Button>
className="flex items-center gap-2" <Button
> disabled={pickedId === currentFolderId || move.isPending}
<Check onClick={async () => {
className={pickedId === p.id ? 'h-4 w-4 opacity-100' : 'h-4 w-4 opacity-0'} try {
/> await move.mutateAsync({ docId: documentId, folderId: pickedId });
<span className="truncate">{p.path}</span> toast.success('Document moved');
</CommandItem> onOpenChange(false);
))} } catch (err) {
</CommandGroup> toastError(err);
) : null} }
</CommandList> }}
</Command> >
<DialogFooter> <FolderInput className="mr-1.5 h-4 w-4" />
<Button variant="outline" onClick={() => onOpenChange(false)}> Move
Cancel </Button>
</Button> </DialogFooter>
<Button </>
disabled={pickedId === currentFolderId || move.isPending}
onClick={async () => {
try {
await move.mutateAsync({ docId: documentId, folderId: pickedId });
toast.success('Document moved');
onOpenChange(false);
} catch (err) {
toastError(err);
}
}}
>
<FolderInput className="mr-1.5 h-4 w-4" />
Move
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
} }

View File

@@ -1,5 +1,5 @@
import type { FilterDefinition } from '@/components/shared/filter-bar'; import type { FilterDefinition } from '@/components/shared/filter-bar';
import { EXPENSE_CATEGORIES } from '@/lib/constants'; import { EXPENSE_CATEGORIES, formatEnum } from '@/lib/constants';
export const expenseFilterDefinitions: FilterDefinition[] = [ export const expenseFilterDefinitions: FilterDefinition[] = [
{ {
@@ -13,7 +13,7 @@ export const expenseFilterDefinitions: FilterDefinition[] = [
label: 'Category', label: 'Category',
type: 'multi-select', type: 'multi-select',
options: EXPENSE_CATEGORIES.map((c) => ({ options: EXPENSE_CATEGORIES.map((c) => ({
label: c.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()), label: formatEnum(c),
value: c, value: c,
})), })),
}, },

View File

@@ -25,7 +25,7 @@ import { TripLabelCombobox } from '@/components/expenses/trip-label-combobox';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import type { z } from 'zod'; import type { z } from 'zod';
import { createExpenseSchema, type CreateExpenseInput } from '@/lib/validators/expenses'; import { createExpenseSchema, type CreateExpenseInput } from '@/lib/validators/expenses';
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants'; import { EXPENSE_CATEGORIES, PAYMENT_METHODS, formatEnum } from '@/lib/constants';
import type { ExpenseRow } from './expense-columns'; import type { ExpenseRow } from './expense-columns';
interface UploadedReceipt { interface UploadedReceipt {
@@ -255,7 +255,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
<SelectContent> <SelectContent>
{EXPENSE_CATEGORIES.map((cat) => ( {EXPENSE_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}> <SelectItem key={cat} value={cat}>
{cat.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())} {formatEnum(cat)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -276,7 +276,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
<SelectContent> <SelectContent>
{PAYMENT_METHODS.map((m) => ( {PAYMENT_METHODS.map((m) => (
<SelectItem key={m} value={m}> <SelectItem key={m} value={m}>
{m.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())} {formatEnum(m)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { ExternalLink, ZoomIn } from 'lucide-react'; import { ExternalLink, ZoomIn } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -37,32 +38,18 @@ export function FilePreviewDialog({
fileName, fileName,
mimeType, mimeType,
}: FilePreviewDialogProps) { }: FilePreviewDialogProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => { // useQuery replaces the prior useEffect(fetch+setState) pattern. The
if (!open || !fileId) { // request is gated on the dialog being open and a fileId being set.
setPreviewUrl(null); const previewQuery = useQuery<{ data: { url: string } }>({
setError(null); queryKey: ['file-preview', fileId],
return; queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`),
} enabled: open && !!fileId,
});
setLoading(true); const previewUrl = previewQuery.data?.data.url ?? null;
setError(null); const loading = previewQuery.isLoading;
const error = previewQuery.error ? 'Failed to load preview' : null;
apiFetch<{ data: { url: string } }>(`/api/v1/files/${fileId}/preview`)
.then((res) => {
setPreviewUrl(res.data.url);
})
.catch(() => {
setError('Failed to load preview');
})
.finally(() => {
setLoading(false);
});
}, [open, fileId]);
const isImage = mimeType?.startsWith('image/'); const isImage = mimeType?.startsWith('image/');
const isPdf = mimeType === 'application/pdf'; const isPdf = mimeType === 'application/pdf';

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf'; import { Document, Page, pdfjs } from 'react-pdf';
import { usePinch } from '@use-gesture/react'; import { usePinch } from '@use-gesture/react';
import { ChevronLeft, ChevronRight, Loader2, Minus, Plus } from 'lucide-react'; import { ChevronLeft, ChevronRight, Loader2, Minus, Plus } from 'lucide-react';
@@ -36,7 +36,15 @@ interface PdfViewerProps {
fileName?: string; fileName?: string;
} }
export function PdfViewer({ url, fileName }: PdfViewerProps) { export function PdfViewer(props: PdfViewerProps) {
// Key-based remount: re-mount the inner body whenever the url
// changes so all local state (page, zoom, error) re-initializes from
// scratch. Replaces the prior useEffect(reset, [url]) the Compiler
// flagged as set-state-in-effect.
return <PdfViewerBody key={props.url} {...props} />;
}
function PdfViewerBody({ url, fileName }: PdfViewerProps) {
const [numPages, setNumPages] = useState<number | null>(null); const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(1); const [pageNumber, setPageNumber] = useState(1);
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
@@ -46,22 +54,12 @@ export function PdfViewer({ url, fileName }: PdfViewerProps) {
// every render — useMemo wins because react-pdf compares by identity. // every render — useMemo wins because react-pdf compares by identity.
const options = useMemo( const options = useMemo(
() => ({ () => ({
// Inline the worker fetch URL above; CMap/StandardFontDataUrl
// pull from the same CDN for unicode + non-system fonts.
cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`, cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`,
standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`, standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`,
}), }),
[], [],
); );
useEffect(() => {
// Reset on url change so navigation between documents lands on
// page 1 at default zoom.
setPageNumber(1);
setScale(1);
setError(null);
}, [url]);
// Pinch-zoom on touch devices. usePinch's `offset` already maps // Pinch-zoom on touch devices. usePinch's `offset` already maps
// gesture distance to a smoothly-changing scalar; we clamp it to // gesture distance to a smoothly-changing scalar; we clamp it to
// the same [0.5, 3] range as the +/- buttons. // the same [0.5, 3] range as the +/- buttons.

View File

@@ -30,11 +30,12 @@ export function InboxPageShell() {
const [remindersOpen, setRemindersOpen] = useState(true); const [remindersOpen, setRemindersOpen] = useState(true);
const { data: alertCount } = useAlertCount(); const { data: alertCount } = useAlertCount();
// Hydrate collapsed state from localStorage on mount. Stored as // localStorage hydration on mount — canonical "read from external
// 'true'/'false' strings; missing keys default to expanded. // store" pattern. setState in effect is intentional.
useEffect(() => { useEffect(() => {
const a = localStorage.getItem('inbox.alerts.open'); const a = localStorage.getItem('inbox.alerts.open');
const r = localStorage.getItem('inbox.reminders.open'); const r = localStorage.getItem('inbox.reminders.open');
// eslint-disable-next-line react-hooks/set-state-in-effect
if (a === 'false') setAlertsOpen(false); if (a === 'false') setAlertsOpen(false);
if (r === 'false') setRemindersOpen(false); if (r === 'false') setRemindersOpen(false);
}, []); }, []);

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
Bell, Bell,
@@ -48,6 +48,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface InterestContactLogTabProps { interface InterestContactLogTabProps {
@@ -158,6 +159,7 @@ function ContactLogRow({
onEdit: (e: ContactLogEntry) => void; onEdit: (e: ContactLogEntry) => void;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const channelMeta = CHANNEL_META[entry.channel]; const channelMeta = CHANNEL_META[entry.channel];
const Icon = channelMeta.icon; const Icon = channelMeta.icon;
@@ -218,10 +220,13 @@ function ContactLogRow({
<DropdownMenuItem <DropdownMenuItem
className="text-destructive" className="text-destructive"
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
onClick={() => { onClick={async () => {
if (window.confirm('Delete this contact log entry?')) { const ok = await confirm({
deleteMutation.mutate(); title: 'Delete contact log entry',
} description: 'This cannot be undone.',
confirmLabel: 'Delete',
});
if (ok) deleteMutation.mutate();
}} }}
> >
<Trash2 className="mr-2 size-3.5" /> <Trash2 className="mr-2 size-3.5" />
@@ -230,6 +235,7 @@ function ContactLogRow({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{confirmDialog}
</li> </li>
); );
} }
@@ -257,7 +263,24 @@ function EmptyState({ onAdd }: { onAdd: () => void }) {
// ─── Compose / edit dialog ─────────────────────────────────────────────────── // ─── Compose / edit dialog ───────────────────────────────────────────────────
function ComposeDialog({ function ComposeDialog(props: {
interestId: string;
existing?: ContactLogEntry;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
// Key-based remount: body keyed on open + existing.id so useState
// initializers re-run each time the dialog opens with a new row.
// Replaces the prior useEffect(setState, [open, existing]) sync.
return (
<ComposeDialogBody
key={props.open ? `open:${props.existing?.id ?? 'new'}` : 'closed'}
{...props}
/>
);
}
function ComposeDialogBody({
interestId, interestId,
existing, existing,
open, open,
@@ -284,21 +307,6 @@ function ComposeDialog({
existing?.followUpAt ? localIsoString(existing.followUpAt) : '', existing?.followUpAt ? localIsoString(existing.followUpAt) : '',
); );
// Re-sync local state when the existing entry changes (e.g. opening
// the edit dialog for a different row). useEffect, not useMemo —
// setState in render is a Compiler red flag.
useEffect(() => {
if (open) {
setOccurredAt(
existing ? localIsoString(existing.occurredAt) : localIsoString(new Date().toISOString()),
);
setChannel(existing?.channel ?? 'phone');
setDirection(existing?.direction ?? 'outbound');
setSummary(existing?.summary ?? '');
setFollowUpAt(existing?.followUpAt ? localIsoString(existing.followUpAt) : '');
}
}, [open, existing]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const body = { const body = {

View File

@@ -22,6 +22,7 @@ import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upl
import { SigningProgress } from '@/components/documents/signing-progress'; import { SigningProgress } from '@/components/documents/signing-progress';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store'; import { useUIStore } from '@/stores/ui-store';
@@ -197,6 +198,7 @@ function ActiveContractCard({
onUploadSigned: () => void; onUploadSigned: () => void;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({ const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', doc.id, 'signers'], queryKey: ['documents', doc.id, 'signers'],
@@ -306,10 +308,13 @@ function ActiveContractCard({
variant="ghost" variant="ghost"
size="sm" size="sm"
disabled={cancelMutation.isPending} disabled={cancelMutation.isPending}
onClick={() => { onClick={async () => {
if (window.confirm('Cancel this contract? Signers will no longer be able to sign.')) { const ok = await confirm({
cancelMutation.mutate(); title: 'Cancel contract',
} description: 'Signers will no longer be able to sign.',
confirmLabel: 'Cancel contract',
});
if (ok) cancelMutation.mutate();
}} }}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3" className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
> >
@@ -318,6 +323,7 @@ function ActiveContractCard({
</Button> </Button>
</div> </div>
</footer> </footer>
{confirmDialog}
</section> </section>
); );
} }

View File

@@ -13,6 +13,7 @@ import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
interface InterestDocumentsTabProps { interface InterestDocumentsTabProps {
@@ -35,6 +36,7 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [eoiDialogOpen, setEoiDialogOpen] = useState(false); const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
const [previewFile, setPreviewFile] = useState<FileRow | null>(null); const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const { confirm, dialog: confirmDialog } = useConfirmation();
const { data: interest } = useQuery<InterestData>({ const { data: interest } = useQuery<InterestData>({
queryKey: ['interests', interestId], queryKey: ['interests', interestId],
@@ -77,7 +79,12 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
}; };
const handleDelete = async (file: FileRow) => { const handleDelete = async (file: FileRow) => {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return; const ok = await confirm({
title: 'Delete file',
description: `Delete "${file.filename}"? This cannot be undone.`,
confirmLabel: 'Delete',
});
if (!ok) return;
try { try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' }); await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: filesQueryKey }); queryClient.invalidateQueries({ queryKey: filesQueryKey });
@@ -167,6 +174,7 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
fileName={previewFile?.filename} fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined} mimeType={previewFile?.mimeType ?? undefined}
/> />
{confirmDialog}
</div> </div>
); );
} }

View File

@@ -23,6 +23,7 @@ import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upl
import { SigningProgress } from '@/components/documents/signing-progress'; import { SigningProgress } from '@/components/documents/signing-progress';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store'; import { useUIStore } from '@/stores/ui-store';
@@ -186,6 +187,7 @@ function ActiveEoiCard({
onUploadSigned: () => void; onUploadSigned: () => void;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({ const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', doc.id, 'signers'], queryKey: ['documents', doc.id, 'signers'],
@@ -295,10 +297,13 @@ function ActiveEoiCard({
variant="ghost" variant="ghost"
size="sm" size="sm"
disabled={cancelMutation.isPending} disabled={cancelMutation.isPending}
onClick={() => { onClick={async () => {
if (window.confirm('Cancel this EOI? Signers will no longer be able to sign.')) { const ok = await confirm({
cancelMutation.mutate(); title: 'Cancel EOI',
} description: 'Signers will no longer be able to sign.',
confirmLabel: 'Cancel EOI',
});
if (ok) cancelMutation.mutate();
}} }}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3" className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
> >
@@ -307,6 +312,7 @@ function ActiveEoiCard({
</Button> </Button>
</div> </div>
</footer> </footer>
{confirmDialog}
</section> </section>
); );
} }

View File

@@ -55,6 +55,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { usePipelineStore } from '@/stores/pipeline-store'; import { usePipelineStore } from '@/stores/pipeline-store';
import { PIPELINE_STAGES, STAGE_LABELS, type PipelineStage } from '@/lib/constants'; import { PIPELINE_STAGES, STAGE_LABELS, type PipelineStage } from '@/lib/constants';
@@ -63,6 +64,7 @@ export function InterestList() {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const { viewMode, setViewMode } = usePipelineStore(); const { viewMode, setViewMode } = usePipelineStore();
// Force the list view at mobile widths even when the user previously // Force the list view at mobile widths even when the user previously
@@ -308,15 +310,14 @@ export function InterestList() {
label: 'Archive', label: 'Archive',
icon: Archive, icon: Archive,
variant: 'destructive', variant: 'destructive',
onClick: (ids) => { onClick: async (ids) => {
if (ids.length === 0) return; if (ids.length === 0) return;
if ( const ok = await confirm({
!window.confirm( title: `Archive ${ids.length} interest${ids.length === 1 ? '' : 's'}`,
`Archive ${ids.length} interest${ids.length === 1 ? '' : 's'}? This can be undone from the archived list.`, description: 'This can be undone from the archived list.',
) confirmLabel: 'Archive',
) { });
return; if (!ok) return;
}
bulkMutation.mutate({ action: 'archive', ids }); bulkMutation.mutate({ action: 'archive', ids });
}, },
}, },
@@ -464,6 +465,7 @@ export function InterestList() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{confirmDialog}
</div> </div>
); );
} }

View File

@@ -22,6 +22,7 @@ import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upl
import { SigningProgress } from '@/components/documents/signing-progress'; import { SigningProgress } from '@/components/documents/signing-progress';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store'; import { useUIStore } from '@/stores/ui-store';
@@ -200,6 +201,7 @@ function ActiveReservationCard({
onUploadSigned: () => void; onUploadSigned: () => void;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({ const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', doc.id, 'signers'], queryKey: ['documents', doc.id, 'signers'],
@@ -309,10 +311,13 @@ function ActiveReservationCard({
variant="ghost" variant="ghost"
size="sm" size="sm"
disabled={cancelMutation.isPending} disabled={cancelMutation.isPending}
onClick={() => { onClick={async () => {
if (window.confirm('Cancel this contract? Signers will no longer be able to sign.')) { const ok = await confirm({
cancelMutation.mutate(); title: 'Cancel contract',
} description: 'Signers will no longer be able to sign.',
confirmLabel: 'Cancel contract',
});
if (ok) cancelMutation.mutate();
}} }}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3" className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
> >
@@ -321,6 +326,7 @@ function ActiveReservationCard({
</Button> </Button>
</div> </div>
</footer> </footer>
{confirmDialog}
</section> </section>
); );
} }

View File

@@ -30,6 +30,7 @@ import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab'; import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab';
import { InterestContractTab } from '@/components/interests/interest-contract-tab'; import { InterestContractTab } from '@/components/interests/interest-contract-tab';
import { InterestReservationTab } from '@/components/interests/interest-reservation-tab'; import { InterestReservationTab } from '@/components/interests/interest-reservation-tab';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -179,7 +180,7 @@ interface MilestoneSectionProps {
hideAutoButton?: boolean; hideAutoButton?: boolean;
}>; }>;
status: string | null; status: string | null;
onAdvance: (stage: string, milestoneDate?: string) => void; onAdvance: (stage: string, milestoneDate?: string) => void | Promise<void>;
isPending: boolean; isPending: boolean;
/** Current pipelineStage. Used to mark steps as done when the pipeline has /** Current pipelineStage. Used to mark steps as done when the pipeline has
* moved past their advanceStage even if the date stamp is missing - e.g. * moved past their advanceStage even if the date stamp is missing - e.g.
@@ -408,7 +409,7 @@ function FutureMilestones({
footer?: React.ReactNode; footer?: React.ReactNode;
}>; }>;
stageMutation: ReturnType<typeof useStageMutation>; stageMutation: ReturnType<typeof useStageMutation>;
advance: (stage: string) => void; advance: (stage: string) => void | Promise<void>;
activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null; activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null;
currentStage: string; currentStage: string;
}) { }) {
@@ -464,6 +465,7 @@ function OverviewTab({
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const mutation = useInterestPatch(interestId); const mutation = useInterestPatch(interestId);
const stageMutation = useStageMutation(interestId); const stageMutation = useStageMutation(interestId);
const { confirm, dialog: confirmDialog } = useConfirmation();
const save = (field: InterestPatchField) => async (next: string | null) => { const save = (field: InterestPatchField) => async (next: string | null) => {
await mutation.mutateAsync({ [field]: next }); await mutation.mutateAsync({ [field]: next });
}; };
@@ -475,17 +477,19 @@ function OverviewTab({
* skip-ahead pattern from the inline stage picker so audit trails * skip-ahead pattern from the inline stage picker so audit trails
* stay consistent regardless of which surface the rep used. * stay consistent regardless of which surface the rep used.
*/ */
const advance = (stage: string, milestoneDate?: string) => { const advance = async (stage: string, milestoneDate?: string) => {
const fromStage = interest.pipelineStage as PipelineStage; const fromStage = interest.pipelineStage as PipelineStage;
const toStage = stage as PipelineStage; const toStage = stage as PipelineStage;
const isOverride = fromStage !== toStage && !canTransitionStage(fromStage, toStage); const isOverride = fromStage !== toStage && !canTransitionStage(fromStage, toStage);
if (isOverride) { if (isOverride) {
const ok = window.confirm( const ok = await confirm({
`This advances the stage from "${fromStage.replace(/_/g, ' ')}" to "${toStage.replace( title: 'Skip-ahead stage change',
description: `This advances the stage from "${fromStage.replace(/_/g, ' ')}" to "${toStage.replace(
/_/g, /_/g,
' ', ' ',
)}", which isn't a standard next step. Continue?\n\nThe change will be flagged in the audit log.`, )}", which isn't a standard next step. The change will be flagged in the audit log.`,
); confirmLabel: 'Continue',
});
if (!ok) return; if (!ok) return;
} }
stageMutation.mutate({ stageMutation.mutate({
@@ -864,6 +868,7 @@ function OverviewTab({
desiredWidthFt={toNum(interest.desiredWidthFt)} desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)} desiredDraftFt={toNum(interest.desiredDraftFt)}
/> />
{confirmDialog}
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -45,10 +45,27 @@ export function NotificationPreferencesForm() {
queryFn: () => queryFn: () =>
apiFetch<{ data: Pref[] }>('/api/v1/notifications/preferences').then((r) => r.data), apiFetch<{ data: Pref[] }>('/api/v1/notifications/preferences').then((r) => r.data),
}); });
// Key-based remount: body keyed on the server payload signature so its
// useState initializer re-runs when prefs land or change. Replaces the
// useEffect(setPrefs, [data]) sync the Compiler flagged.
const signature = data
? data.map((p) => `${p.notificationType}:${p.inApp ? 1 : 0}:${p.email ? 1 : 0}`).join('|')
: 'loading';
return (
<NotificationPreferencesFormBody key={signature} data={data} isLoading={isLoading} qc={qc} />
);
}
const [prefs, setPrefs] = useState<Map<string, Pref>>(new Map()); function NotificationPreferencesFormBody({
data,
useEffect(() => { isLoading,
qc,
}: {
data: Pref[] | undefined;
isLoading: boolean;
qc: ReturnType<typeof useQueryClient>;
}) {
const [prefs, setPrefs] = useState<Map<string, Pref>>(() => {
const map = new Map<string, Pref>(); const map = new Map<string, Pref>();
for (const t of KNOWN_TYPES) { for (const t of KNOWN_TYPES) {
map.set(t.key, { notificationType: t.key, inApp: true, email: true }); map.set(t.key, { notificationType: t.key, inApp: true, email: true });
@@ -58,8 +75,8 @@ export function NotificationPreferencesForm() {
map.set(p.notificationType, p); map.set(p.notificationType, p);
} }
} }
setPrefs(map); return map;
}, [data]); });
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async () => { mutationFn: async () => {

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -49,23 +49,31 @@ export function ReminderDigestForm() {
queryFn: () => queryFn: () =>
apiFetch<{ data: UserPrefsResponse }>('/api/v1/users/me/preferences').then((r) => r.data), apiFetch<{ data: UserPrefsResponse }>('/api/v1/users/me/preferences').then((r) => r.data),
}); });
// Key-based remount: the body is keyed on the loaded payload signature
// so the useState initializers re-run when the server data first lands
// or genuinely changes. Replaces the prior useEffect(setState) sync.
if (isLoading || !data) {
return <ReminderDigestFormBody key="loading" data={data} isLoading={isLoading} qc={qc} />;
}
const r = data.reminders ?? null;
const signature = `${r?.delivery ?? ''}|${r?.digestTime ?? ''}|${r?.digestDayOfWeek ?? ''}|${r?.timezone ?? data.timezone ?? ''}`;
return <ReminderDigestFormBody key={signature} data={data} isLoading={false} qc={qc} />;
}
const [delivery, setDelivery] = useState<ReminderPrefs['delivery']>('immediate'); function ReminderDigestFormBody({
const [digestTime, setDigestTime] = useState('09:00'); data,
const [digestDay, setDigestDay] = useState('1'); isLoading,
const [timezone, setTimezone] = useState('Europe/Warsaw'); qc,
}: {
useEffect(() => { data: UserPrefsResponse | undefined;
const r = data?.reminders; isLoading: boolean;
if (r) { qc: ReturnType<typeof useQueryClient>;
setDelivery(r.delivery ?? 'immediate'); }) {
setDigestTime(r.digestTime ?? '09:00'); const r = data?.reminders;
setDigestDay(String(r.digestDayOfWeek ?? 1)); const [delivery, setDelivery] = useState<ReminderPrefs['delivery']>(r?.delivery ?? 'immediate');
setTimezone(r.timezone ?? data?.timezone ?? 'Europe/Warsaw'); const [digestTime, setDigestTime] = useState(r?.digestTime ?? '09:00');
} else if (data?.timezone) { const [digestDay, setDigestDay] = useState(String(r?.digestDayOfWeek ?? 1));
setTimezone(data.timezone); const [timezone, setTimezone] = useState(r?.timezone ?? data?.timezone ?? 'Europe/Warsaw');
}
}, [data]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -45,7 +46,20 @@ interface ReminderFormProps {
onSuccess: () => void; onSuccess: () => void;
} }
export function ReminderForm({ export function ReminderForm(props: ReminderFormProps) {
// Key-based remount: the body is keyed on `open + reminder.id` so its
// useState initializers re-run on each open with the correct seed.
// Replaces the prior useEffect-driven open→reset that the Compiler
// flagged as set-state-in-effect.
return (
<ReminderFormBody
key={props.open ? `open:${props.reminder?.id ?? 'new'}` : 'closed'}
{...props}
/>
);
}
function ReminderFormBody({
open, open,
onOpenChange, onOpenChange,
reminder, reminder,
@@ -54,58 +68,33 @@ export function ReminderForm({
defaultBerthId, defaultBerthId,
onSuccess, onSuccess,
}: ReminderFormProps) { }: ReminderFormProps) {
const [title, setTitle] = useState(''); const isEdit = !!reminder;
const [note, setNote] = useState(''); // Tomorrow 9am default for new-reminder dueAt.
const [dueAt, setDueAt] = useState(''); const defaultDueAt = useMemo(() => {
const [priority, setPriority] = useState('medium'); const t = new Date();
const [assignedTo, setAssignedTo] = useState(''); t.setDate(t.getDate() + 1);
const [clientId, setClientId] = useState(''); t.setHours(9, 0, 0, 0);
const [interestId, setInterestId] = useState(''); return t.toISOString().slice(0, 16);
const [berthId, setBerthId] = useState(''); }, []);
const [users, setUsers] = useState<UserOption[]>([]); const [title, setTitle] = useState(reminder?.title ?? '');
const [note, setNote] = useState(reminder?.note ?? '');
const [dueAt, setDueAt] = useState(reminder ? reminder.dueAt.slice(0, 16) : defaultDueAt);
const [priority, setPriority] = useState(reminder?.priority ?? 'medium');
const [assignedTo, setAssignedTo] = useState(reminder?.assignedTo ?? '');
const [clientId, setClientId] = useState(reminder?.clientId ?? defaultClientId ?? '');
const [interestId, setInterestId] = useState(reminder?.interestId ?? defaultInterestId ?? '');
const [berthId, setBerthId] = useState(reminder?.berthId ?? defaultBerthId ?? '');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { can } = usePermissions(); const { can } = usePermissions();
const canAssignOthers = can('reminders', 'assign_others'); const canAssignOthers = can('reminders', 'assign_others');
// useQuery replaces the prior useEffect(fetch+setState) pattern.
const isEdit = !!reminder; const usersQuery = useQuery<{ data: UserOption[] }>({
queryKey: ['admin', 'users', 'options'],
useEffect(() => { queryFn: () => apiFetch('/api/v1/admin/users/options'),
if (open && canAssignOthers) { enabled: open && canAssignOthers,
void apiFetch<{ data: UserOption[] }>('/api/v1/admin/users/options').then((res) => });
setUsers(res.data), const users = usersQuery.data?.data ?? [];
);
}
}, [open, canAssignOthers]);
useEffect(() => {
if (open) {
if (reminder) {
setTitle(reminder.title);
setNote(reminder.note ?? '');
setDueAt(reminder.dueAt.slice(0, 16)); // datetime-local format
setPriority(reminder.priority);
setAssignedTo(reminder.assignedTo ?? '');
setClientId(reminder.clientId ?? '');
setInterestId(reminder.interestId ?? '');
setBerthId(reminder.berthId ?? '');
} else {
setTitle('');
setNote('');
// Default to tomorrow 9 AM
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
setDueAt(tomorrow.toISOString().slice(0, 16));
setPriority('medium');
setAssignedTo('');
setClientId(defaultClientId ?? '');
setInterestId(defaultInterestId ?? '');
setBerthId(defaultBerthId ?? '');
}
setError(null);
}
}, [open, reminder, defaultClientId, defaultInterestId, defaultBerthId]);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { Plus, CheckCircle2, Clock, Pencil, XCircle, AlertTriangle, Bell } from 'lucide-react'; import { Plus, CheckCircle2, Clock, Pencil, XCircle, AlertTriangle, Bell } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
@@ -71,8 +72,6 @@ interface ReminderListProps {
} }
export function ReminderList({ embedded = false }: ReminderListProps = {}) { export function ReminderList({ embedded = false }: ReminderListProps = {}) {
const [reminders, setReminders] = useState<Reminder[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
useCreateFromUrl(() => setFormOpen(true)); useCreateFromUrl(() => setFormOpen(true));
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null); const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
@@ -80,57 +79,50 @@ export function ReminderList({ embedded = false }: ReminderListProps = {}) {
const [viewMode, setViewMode] = useState<'my' | 'all'>('my'); const [viewMode, setViewMode] = useState<'my' | 'all'>('my');
const [statusFilter, setStatusFilter] = useState<string>('active'); const [statusFilter, setStatusFilter] = useState<string>('active');
const [priorityFilter, setPriorityFilter] = useState<string>('all'); const [priorityFilter, setPriorityFilter] = useState<string>('all');
const [total, setTotal] = useState(0);
const { can } = usePermissions(); const { can } = usePermissions();
const canViewAll = can('reminders', 'view_all'); const canViewAll = can('reminders', 'view_all');
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const fetchReminders = useCallback(async () => { // useQuery replaces the prior useEffect(fetch+setState) pattern.
setLoading(true); // The query key captures every filter so a switch refetches; the
try { // mutation handlers below invalidate-by-prefix to refresh after
// complete/dismiss.
const remindersQuery = useQuery<{ reminders: Reminder[]; total: number }>({
queryKey: ['reminders', viewMode, statusFilter, priorityFilter],
queryFn: async () => {
if (viewMode === 'my') { if (viewMode === 'my') {
const res = await apiFetch<{ data: Reminder[] }>('/api/v1/reminders/my'); const res = await apiFetch<{ data: Reminder[] }>('/api/v1/reminders/my');
let filtered = res.data; const filtered =
if (priorityFilter !== 'all') { priorityFilter === 'all'
filtered = filtered.filter((r) => r.priority === priorityFilter); ? res.data
} : res.data.filter((r) => r.priority === priorityFilter);
setReminders(filtered); return { reminders: filtered, total: filtered.length };
setTotal(filtered.length);
} else {
const params = new URLSearchParams({ limit: '50', order: 'asc', sort: 'dueAt' });
if (statusFilter === 'active') {
params.set('status', 'pending');
} else if (statusFilter !== 'all') {
params.set('status', statusFilter);
}
if (priorityFilter !== 'all') {
params.set('priority', priorityFilter);
}
const res = await apiFetch<{
data: Reminder[];
pagination: { total: number };
}>(`/api/v1/reminders?${params}`);
setReminders(res.data);
setTotal(res.pagination.total);
} }
} finally { const sp = new URLSearchParams({ limit: '50', order: 'asc', sort: 'dueAt' });
setLoading(false); if (statusFilter === 'active') sp.set('status', 'pending');
} else if (statusFilter !== 'all') sp.set('status', statusFilter);
}, [viewMode, statusFilter, priorityFilter]); if (priorityFilter !== 'all') sp.set('priority', priorityFilter);
const res = await apiFetch<{
useEffect(() => { data: Reminder[];
void fetchReminders(); pagination: { total: number };
}, [fetchReminders]); }>(`/api/v1/reminders?${sp}`);
return { reminders: res.data, total: res.pagination.total };
},
});
const reminders = remindersQuery.data?.reminders ?? [];
const total = remindersQuery.data?.total ?? 0;
const loading = remindersQuery.isLoading;
async function handleComplete(id: string) { async function handleComplete(id: string) {
await apiFetch(`/api/v1/reminders/${id}/complete`, { method: 'POST' }); await apiFetch(`/api/v1/reminders/${id}/complete`, { method: 'POST' });
await fetchReminders(); void queryClient.invalidateQueries({ queryKey: ['reminders'] });
} }
async function handleDismiss(id: string) { async function handleDismiss(id: string) {
await apiFetch(`/api/v1/reminders/${id}/dismiss`, { method: 'POST' }); await apiFetch(`/api/v1/reminders/${id}/dismiss`, { method: 'POST' });
await fetchReminders(); void queryClient.invalidateQueries({ queryKey: ['reminders'] });
} }
function isOverdue(dueAt: string, status: string): boolean { function isOverdue(dueAt: string, status: string): boolean {
@@ -399,7 +391,7 @@ export function ReminderList({ embedded = false }: ReminderListProps = {}) {
open={formOpen} open={formOpen}
onOpenChange={setFormOpen} onOpenChange={setFormOpen}
reminder={editingReminder} reminder={editingReminder}
onSuccess={fetchReminders} onSuccess={() => queryClient.invalidateQueries({ queryKey: ['reminders'] })}
/> />
<SnoozeDialog <SnoozeDialog
@@ -408,7 +400,7 @@ export function ReminderList({ embedded = false }: ReminderListProps = {}) {
if (!open) setSnoozingId(null); if (!open) setSnoozingId(null);
}} }}
reminderId={snoozingId} reminderId={snoozingId}
onSuccess={fetchReminders} onSuccess={() => queryClient.invalidateQueries({ queryKey: ['reminders'] })}
/> />
</div> </div>
); );

View File

@@ -128,6 +128,9 @@ export function CommandSearch() {
const [lastAllTotals, setLastAllTotals] = useState<SearchResults['totals'] | null>(null); const [lastAllTotals, setLastAllTotals] = useState<SearchResults['totals'] | null>(null);
useEffect(() => { useEffect(() => {
if (activeBucket === 'all' && results?.totals) { if (activeBucket === 'all' && results?.totals) {
// Snapshot the totals at the moment the user is on "All" so the
// chips stay stable when they switch to a filtered bucket.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLastAllTotals(results.totals); setLastAllTotals(results.totals);
} }
}, [activeBucket, results]); }, [activeBucket, results]);
@@ -220,8 +223,10 @@ export function CommandSearch() {
}); });
}, [showDropdown, query, results, recentlyViewed, recentSearches, activeBucket, portSlug]); }, [showDropdown, query, results, recentlyViewed, recentSearches, activeBucket, portSlug]);
// Reset focus index when the visible row set changes. // Reset focus index when the visible row set changes. The set-state
// is intentional — external state (bucket / query) drives focus.
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFocusIndex(-1); setFocusIndex(-1);
}, [activeBucket, query]); }, [activeBucket, query]);

View File

@@ -70,11 +70,14 @@ export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayP
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setVisibleHeight(null); setVisibleHeight(null);
return; return;
} }
const vv = window.visualViewport; const vv = window.visualViewport;
if (!vv) return; if (!vv) return;
// Subscribing to the visualViewport external store — canonical
// useEffect+setState shape for that pattern.
const update = () => setVisibleHeight(vv.height); const update = () => setVisibleHeight(vv.height);
update(); update();
vv.addEventListener('resize', update); vv.addEventListener('resize', update);
@@ -98,6 +101,7 @@ export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayP
const [lastAllTotals, setLastAllTotals] = useState<SearchResults['totals'] | null>(null); const [lastAllTotals, setLastAllTotals] = useState<SearchResults['totals'] | null>(null);
useEffect(() => { useEffect(() => {
if (activeBucket === 'all' && results?.totals) { if (activeBucket === 'all' && results?.totals) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLastAllTotals(results.totals); setLastAllTotals(results.totals);
} }
}, [activeBucket, results]); }, [activeBucket, results]);
@@ -120,8 +124,11 @@ export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayP
// Reset query when the drawer closes. Without this, reopening the // Reset query when the drawer closes. Without this, reopening the
// overlay would flash stale results before the empty state renders. // overlay would flash stale results before the empty state renders.
// setState in effect is intentional — the trigger is a discrete
// open/close transition.
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setQuery(''); setQuery('');
setActiveBucket('all'); setActiveBucket('all');
} }

View File

@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { WarningCallout } from '@/components/ui/warning-callout';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { CountryCombobox } from '@/components/shared/country-combobox'; import { CountryCombobox } from '@/components/shared/country-combobox';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
@@ -91,11 +92,12 @@ export function UserSettings() {
}, [detectedTz]); }, [detectedTz]);
// When the user picks a country and no timezone is set, suggest the // When the user picks a country and no timezone is set, suggest the
// primary zone for that country. Doesn't fight an explicit timezone // primary zone for that country. setState in effect is intentional —
// selection — only fires while the timezone slot is empty. // we're reacting to a discrete user choice (country picker).
useEffect(() => { useEffect(() => {
if (!country || timezone) return; if (!country || timezone) return;
const primary = primaryTimezoneFor(country as CountryCode); const primary = primaryTimezoneFor(country as CountryCode);
// eslint-disable-next-line react-hooks/set-state-in-effect
if (primary) setTimezone(primary); if (primary) setTimezone(primary);
}, [country, timezone]); }, [country, timezone]);
@@ -319,20 +321,22 @@ export function UserSettings() {
countryHint={(country as CountryCode | null) ?? undefined} countryHint={(country as CountryCode | null) ?? undefined}
/> />
{tzMismatch && ( {tzMismatch && (
<div className="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900"> <WarningCallout icon={false}>
<Globe className="h-3.5 w-3.5 shrink-0 mt-0.5" /> <span className="flex items-start gap-2 text-xs">
<div className="flex-1"> <Globe aria-hidden className="h-3.5 w-3.5 shrink-0 mt-0.5" />
Looks like you&apos;re in <strong>{detectedTz}</strong> right now (saved:{' '} <span className="flex-1">
{timezone}). Looks like you&apos;re in <strong>{detectedTz}</strong> right now (saved:{' '}
<button {timezone}).
type="button" <button
onClick={adoptDetectedTz} type="button"
className="ml-1 underline underline-offset-2 hover:no-underline" onClick={adoptDetectedTz}
> className="ml-1 underline underline-offset-2 hover:no-underline"
Update? >
</button> Update?
</div> </button>
</div> </span>
</span>
</WarningCallout>
)} )}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input';
import { CountryCombobox } from '@/components/shared/country-combobox'; import { CountryCombobox } from '@/components/shared/country-combobox';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox'; import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
@@ -41,6 +42,7 @@ interface AddressesEditorProps {
export function AddressesEditor({ endpoint, invalidateKey, addresses }: AddressesEditorProps) { export function AddressesEditor({ endpoint, invalidateKey, addresses }: AddressesEditorProps) {
const qc = useQueryClient(); const qc = useQueryClient();
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const { confirm, dialog: confirmDialog } = useConfirmation();
function invalidate() { function invalidate() {
qc.invalidateQueries({ queryKey: invalidateKey }); qc.invalidateQueries({ queryKey: invalidateKey });
@@ -74,7 +76,12 @@ export function AddressesEditor({ endpoint, invalidateKey, addresses }: Addresse
address={a} address={a}
onUpdate={(patch) => updateMutation.mutateAsync({ id: a.id, patch })} onUpdate={(patch) => updateMutation.mutateAsync({ id: a.id, patch })}
onRemove={async () => { onRemove={async () => {
if (!confirm('Remove this address?')) return; const ok = await confirm({
title: 'Remove address',
description: 'Remove this address?',
confirmLabel: 'Remove',
});
if (!ok) return;
await removeMutation.mutateAsync(a.id); await removeMutation.mutateAsync(a.id);
}} }}
/> />
@@ -102,6 +109,7 @@ export function AddressesEditor({ endpoint, invalidateKey, addresses }: Addresse
Add address Add address
</Button> </Button>
)} )}
{confirmDialog}
</div> </div>
); );
} }

View File

@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { STAGE_LABELS, formatSource, type PipelineStage } from '@/lib/constants'; import { STAGE_LABELS, formatEnum, formatSource, type PipelineStage } from '@/lib/constants';
interface AuditRow { interface AuditRow {
id: string; id: string;
@@ -51,11 +51,11 @@ function formatValueForField(field: string | null, value: unknown): string {
const f = field.replace(/_/g, '').toLowerCase(); const f = field.replace(/_/g, '').toLowerCase();
if (typeof value === 'string') { if (typeof value === 'string') {
if (f === 'pipelinestage' || f === 'stage') { if (f === 'pipelinestage' || f === 'stage') {
return STAGE_LABELS[value as PipelineStage] ?? value.replace(/_/g, ' '); return STAGE_LABELS[value as PipelineStage] ?? formatEnum(value);
} }
if (f === 'source') return formatSource(value) ?? value; if (f === 'source') return formatSource(value) ?? value;
if (f === 'leadcategory' || f === 'category' || f === 'outcome') { if (f === 'leadcategory' || f === 'category' || f === 'outcome') {
return value.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); return formatEnum(value);
} }
} }
} }

View File

@@ -66,6 +66,14 @@ export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaPr
* Enter/blur and cancels on Escape. * Enter/blur and cancels on Escape.
*/ */
export function InlineEditableField(props: InlineEditableFieldProps) { export function InlineEditableField(props: InlineEditableFieldProps) {
// Key-based remount: keying the inner body on `value` re-runs the
// `useState(value)` initializer whenever the prop changes externally
// (optimistic-update settle, parent refetch). Replaces the prior
// useEffect(setDraft, [value]) Compiler-flagged set-state-in-effect.
return <InlineEditableFieldBody key={String(props.value ?? '')} {...props} />;
}
function InlineEditableFieldBody(props: InlineEditableFieldProps) {
const { value, displayValue, onSave, placeholder, emptyText = '-', className, disabled } = props; const { value, displayValue, onSave, placeholder, emptyText = '-', className, disabled } = props;
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? ''); const [draft, setDraft] = useState(value ?? '');
@@ -73,10 +81,6 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setDraft(value ?? '');
}, [value]);
useEffect(() => { useEffect(() => {
if (editing) { if (editing) {
if (inputRef.current) { if (inputRef.current) {

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react'; import { Check, ChevronsUpDown } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -42,15 +42,16 @@ export function OwnerPicker({
disabled, disabled,
}: OwnerPickerProps) { }: OwnerPickerProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [type, setType] = useState<'client' | 'company'>(value?.type ?? 'client'); // `type` is derived: when an owner is selected the prop wins; with no
// selection the user's local tab pick is the source of truth. Render-
// phase derivation replaces the prior useEffect(setType, [value?.type])
// that the Compiler flagged as set-state-in-effect.
const [localType, setLocalType] = useState<'client' | 'company'>(value?.type ?? 'client');
const type: 'client' | 'company' = value?.type ?? localType;
const setType = setLocalType;
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const debounced = useDebounce(search, 300); const debounced = useDebounce(search, 300);
// Keep local `type` in sync if value.type changes externally.
useEffect(() => {
if (value?.type) setType(value.type);
}, [value?.type]);
const endpoint = const endpoint =
type === 'client' type === 'client'
? `/api/v1/clients/options?search=${encodeURIComponent(debounced)}` ? `/api/v1/clients/options?search=${encodeURIComponent(debounced)}`

View File

@@ -36,18 +36,15 @@ export function ReminderDaysInput({
className, className,
}: ReminderDaysInputProps) { }: ReminderDaysInputProps) {
const isPreset = typeof value === 'number' && (PRESETS as readonly number[]).includes(value); const isPreset = typeof value === 'number' && (PRESETS as readonly number[]).includes(value);
const [customStr, setCustomStr] = React.useState<string>(() => // Derived from the prop: a non-preset numeric value renders its string
!isPreset && typeof value === 'number' ? String(value) : '', // form; null/preset values show empty. Local edits flow through onChange
); // and bounce back via `value` so render-phase derivation stays correct.
const customStr = !isPreset && typeof value === 'number' ? String(value) : '';
// Sync external value → custom input when it changes to a non-preset. const [draftStr, setDraftStr] = React.useState<string>(customStr);
React.useEffect(() => { // When the user is mid-typing (`draftStr` set), keep their text; otherwise
if (typeof value === 'number' && !(PRESETS as readonly number[]).includes(value)) { // show the derived value. Resets when value changes externally because
setCustomStr(String(value)); // the derived `customStr` overrides on commit.
} else if (value == null) { const shownStr = draftStr !== '' ? draftStr : customStr;
setCustomStr('');
}
}, [value]);
return ( return (
<div className={cn('space-y-2', className)}> <div className={cn('space-y-2', className)}>
@@ -79,10 +76,10 @@ export function ReminderDaysInput({
step={1} step={1}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
value={customStr} value={shownStr}
onChange={(e) => { onChange={(e) => {
const raw = e.target.value; const raw = e.target.value;
setCustomStr(raw); setDraftStr(raw);
if (raw === '') { if (raw === '') {
onChange(null); onChange(null);
return; return;
@@ -90,6 +87,7 @@ export function ReminderDaysInput({
const n = Number.parseInt(raw, 10); const n = Number.parseInt(raw, 10);
if (Number.isFinite(n) && n > 0) onChange(n); if (Number.isFinite(n) && n > 0) onChange(n);
}} }}
onBlur={() => setDraftStr('')}
/> />
</div> </div>
); );

View File

@@ -15,7 +15,7 @@
* - Body length capped at 50KB; char count visible. * - Body length capped at 50KB; char count visible.
*/ */
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -59,7 +59,20 @@ interface PreviewResponse {
data: { html: string; markdown: string; unresolved: string[] }; data: { html: string; markdown: string; unresolved: string[] };
} }
export function SendDocumentDialog({ export function SendDocumentDialog(props: SendDocumentDialogProps) {
// Key-based remount: the body is keyed on `open + recipient.email` so
// its useState initializers re-run each time the dialog opens with a
// new recipient. Replaces the prior useEffect-driven reset that the
// Compiler flagged as set-state-in-effect.
return (
<SendDocumentDialogInner
key={props.open ? `open:${props.recipient.email ?? ''}` : 'closed'}
{...props}
/>
);
}
function SendDocumentDialogInner({
open, open,
onOpenChange, onOpenChange,
documentKind, documentKind,
@@ -73,14 +86,6 @@ export function SendDocumentDialog({
const [emailOverride, setEmailOverride] = useState(recipient.email ?? ''); const [emailOverride, setEmailOverride] = useState(recipient.email ?? '');
const [customBody, setCustomBody] = useState(''); const [customBody, setCustomBody] = useState('');
useEffect(() => {
if (open) {
setStep('compose');
setEmailOverride(recipient.email ?? '');
setCustomBody('');
}
}, [open, recipient.email]);
const recipientForApi = useMemo( const recipientForApi = useMemo(
() => ({ () => ({
clientId: recipient.clientId, clientId: recipient.clientId,

View File

@@ -65,6 +65,11 @@ const Carousel = React.forwardRef<
React.useEffect(() => { React.useEffect(() => {
if (!api) return; if (!api) return;
// Embla doesn't fire 'select' on mount, so we sync state from the
// external store once here. This is the canonical pattern for
// subscribing to a third-party event source and is intentionally
// flagged-but-allowed.
// eslint-disable-next-line react-hooks/set-state-in-effect
onSelect(api); onSelect(api);
api.on('reInit', onSelect); api.on('reInit', onSelect);
api.on('select', onSelect); api.on('select', onSelect);

View File

@@ -0,0 +1,48 @@
import * as React from 'react';
import { AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Compact amber-bordered alert callout. Replaces the dozen-plus ad-hoc
* `border-amber-{200,300} bg-amber-50` `<div>`s scattered through the
* codebase that the ui/ux audit (L5) flagged as drift.
*
* Renders a leading triangle icon by default; pass `icon={false}` to
* suppress when the surrounding layout already conveys the warning
* semantics. The icon is `aria-hidden` because the text content always
* carries the meaning.
*/
export interface WarningCalloutProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
/** Optional pre-content heading rendered bolder than the body. */
title?: React.ReactNode;
/** Set false to suppress the leading icon. */
icon?: boolean;
}
export function WarningCallout({
title,
icon = true,
className,
children,
...rest
}: WarningCalloutProps) {
return (
<div
role="status"
className={cn(
'rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900',
className,
)}
{...rest}
>
<div className="flex items-start gap-2">
{icon ? <AlertTriangle aria-hidden className="mt-0.5 size-4 shrink-0" /> : null}
<div className="min-w-0 flex-1 space-y-1">
{title ? <div className="font-medium">{title}</div> : null}
<div>{children}</div>
</div>
</div>
</div>
);
}

View File

@@ -30,12 +30,14 @@ import { getYachtColumns, type YachtRow } from '@/components/yachts/yacht-column
import { useCreateFromUrl } from '@/hooks/use-create-from-url'; import { useCreateFromUrl } from '@/hooks/use-create-from-url';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
export function YachtList() { export function YachtList() {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true)); useCreateFromUrl(() => setCreateOpen(true));
@@ -181,15 +183,14 @@ export function YachtList() {
label: 'Archive', label: 'Archive',
icon: Archive, icon: Archive,
variant: 'destructive', variant: 'destructive',
onClick: (ids) => { onClick: async (ids) => {
if (ids.length === 0) return; if (ids.length === 0) return;
if ( const ok = await confirm({
!window.confirm( title: `Archive ${ids.length} yacht${ids.length === 1 ? '' : 's'}`,
`Archive ${ids.length} yacht${ids.length === 1 ? '' : 's'}? This can be undone from the archived list.`, description: 'This can be undone from the archived list.',
) confirmLabel: 'Archive',
) { });
return; if (!ok) return;
}
bulkMutation.mutate({ action: 'archive', ids }); bulkMutation.mutate({ action: 'archive', ids });
}, },
}, },
@@ -290,6 +291,7 @@ export function YachtList() {
onConfirm={() => archiveYacht && archiveMutation.mutate(archiveYacht.id)} onConfirm={() => archiveYacht && archiveMutation.mutate(archiveYacht.id)}
isLoading={archiveMutation.isPending} isLoading={archiveMutation.isPending}
/> />
{confirmDialog}
</div> </div>
); );
} }

View File

@@ -0,0 +1,117 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { cn } from '@/lib/utils';
interface ConfirmOptions {
title: string;
description: string;
/** Confirm button label. Default: "Delete". */
confirmLabel?: string;
/** Cancel button label. Default: "Cancel". */
cancelLabel?: string;
/** When true, confirm button is rendered in destructive red. Default: true. */
destructive?: boolean;
}
/**
* Programmatic, awaitable confirmation dialog — the imperative counterpart
* to `<ConfirmationDialog>` (which needs a trigger element).
*
* Replaces the native `window.confirm()` pattern in 17 destructive flows
* the audit's UI-UX pass flagged. `confirm(...)` is synchronous and
* browser-styled (escape-hatch UX); this hook is async, accessible
* (alert-dialog semantics + focus trap), keyboard-navigable, and matches
* the rest of the app's visual language.
*
* Usage:
* const { confirm, dialog } = useConfirmation();
*
* async function handleDelete(file) {
* const ok = await confirm({
* title: 'Delete file',
* description: `Delete "${file.filename}"? This cannot be undone.`,
* confirmLabel: 'Delete',
* });
* if (!ok) return;
* // ... actually delete
* }
*
* return <>{children}{dialog}</>;
*
* The dialog renders into the component's tree once, and `confirm()`
* resolves with the user's choice. Multiple sequential `confirm()`
* calls are safe — each gets its own promise.
*/
export function useConfirmation() {
const [state, setState] = useState<(ConfirmOptions & { open: boolean }) | null>(null);
const resolverRef = useRef<((ok: boolean) => void) | null>(null);
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
// If a previous confirm is somehow still open, resolve it as a cancel
// before starting the next. Defensive against rapid double-fires.
if (resolverRef.current) {
resolverRef.current(false);
resolverRef.current = null;
}
setState({ ...opts, open: true });
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
});
}, []);
const handleConfirm = useCallback(() => {
resolverRef.current?.(true);
resolverRef.current = null;
setState((prev) => (prev ? { ...prev, open: false } : null));
}, []);
const handleCancel = useCallback(() => {
resolverRef.current?.(false);
resolverRef.current = null;
setState((prev) => (prev ? { ...prev, open: false } : null));
}, []);
const dialog = state ? (
<AlertDialog
open={state.open}
onOpenChange={(open) => {
if (!open) handleCancel();
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{state.title}</AlertDialogTitle>
<AlertDialogDescription>{state.description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>
{state.cancelLabel ?? 'Cancel'}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
className={cn(
state.destructive !== false &&
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
)}
>
{state.confirmLabel ?? 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null;
return { confirm, dialog };
}

View File

@@ -40,17 +40,12 @@ export function useTablePreferences(entityType: string, defaultHidden: string[]
const remoteHidden = const remoteHidden =
meQuery.data?.data.preferences?.tablePreferences?.[entityType]?.hiddenColumns; meQuery.data?.data.preferences?.tablePreferences?.[entityType]?.hiddenColumns;
// Local edits win over the server-loaded prefs. The render-phase
// derivation below (line 107: `localHidden ?? remoteHidden ?? defaultHidden`)
// replaces the prior useEffect(setLocalHidden, [remoteHidden]) sync
// that the Compiler flagged as set-state-in-effect.
const [localHidden, setLocalHidden] = useState<string[] | null>(null); const [localHidden, setLocalHidden] = useState<string[] | null>(null);
// When the remote preferences arrive (or change), seed the local
// state. We only sync from remote → local on first load or when the
// server side genuinely changes (e.g. another tab updated prefs).
useEffect(() => {
if (remoteHidden && localHidden === null) {
setLocalHidden(remoteHidden);
}
}, [remoteHidden, localHidden]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setHidden = useCallback( const setHidden = useCallback(

View File

@@ -389,3 +389,54 @@ export function withRateLimit(name: RateLimiterName, handler: RouteHandler): Rou
return handler(req, ctx, params); return handler(req, ctx, params);
}; };
} }
// ─── withPublicContext ───────────────────────────────────────────────────────
/**
* Wraps a public (unauthenticated) route — webhooks, health checks,
* public APIs — so it runs inside the same `runWithRequestContext` ALS
* frame that `withAuth` installs for authenticated routes. Without this
* frame, `captureErrorEvent`, `getRequestId`, and the logger's request-id
* mixin silently no-op for these endpoints, leaving webhook failures
* invisible in the platform-errors dashboard.
*
* Top-level errors thrown by the handler are forwarded to
* `captureErrorEvent` (so they surface in admin/errors) and re-raised
* so Next's runtime can return a 500. Webhook handlers that prefer to
* always-return-200 can catch internally — this wrapper only catches the
* uncaught path.
*/
export function withPublicContext(
handler: (req: NextRequest) => Promise<NextResponse>,
): (req: NextRequest) => Promise<NextResponse> {
return async (req) => {
const incomingId = req.headers.get('x-request-id');
const requestId =
incomingId && /^[A-Za-z0-9-]{8,64}$/.test(incomingId) ? incomingId : randomUUID();
const tag = (res: NextResponse): NextResponse => {
res.headers.set('X-Request-Id', requestId);
return res;
};
return runWithRequestContext(
{
requestId,
portId: '',
userId: '',
method: req.method,
path: new URL(req.url).pathname,
startedAt: Date.now(),
},
async () => {
try {
return tag(await handler(req));
} catch (error) {
const { captureErrorEvent } = await import('@/lib/services/error-events.service');
void captureErrorEvent({ statusCode: 500, error });
logger.error({ err: error }, 'Public route handler threw');
return tag(errorResponse(error));
}
},
);
};
}

View File

@@ -79,26 +79,101 @@ export interface AuditLogParams {
source?: AuditSource; source?: AuditSource;
} }
const SENSITIVE_FIELDS = new Set(['email', 'phone', 'password', 'credentials_enc', 'token']); // Lower-cased key fragments. A metadata key is masked if any fragment is
// contained as a substring after lowercase + snake/kebab normalization.
// Substring match catches `recipientEmail`, `sent_to_email`, `userEmail`,
// `attempted_email`, `from_address`, `phone_number`, `passwordHash`, etc.
const SENSITIVE_KEY_FRAGMENTS = [
'email',
'phone',
'password',
'token',
'credentials',
'secret',
'api_key',
'apikey',
'auth',
'authorization',
'cookie',
'address', // physical/mailing addresses
'dob',
'date_of_birth',
'birthdate',
'tax_id',
'taxid',
'national_id',
'ssn',
'passport',
'iban',
'card_number',
'cvv',
'recipient', // e.g. recipientEmail catches the parent too — preserves intent
'first_name',
'last_name',
'full_name',
'fullname',
];
function isSensitiveKey(key: string): boolean {
const k = key.toLowerCase().replace(/[-]/g, '_');
return SENSITIVE_KEY_FRAGMENTS.some((frag) => k.includes(frag));
}
function maskString(val: string): string {
return val.length > 4 ? `${val.slice(0, 2)}***${val.slice(-2)}` : '***';
}
function maskValue(value: unknown, depth: number): unknown {
if (depth > 4) return '[depth-limit]';
if (value === null || value === undefined) return value;
if (typeof value === 'string') return maskString(value);
if (Array.isArray(value)) return value.map((v) => maskValue(v, depth + 1));
if (typeof value === 'object') {
// Recurse into nested object — only mask keys that themselves look
// sensitive. Parents stay traversable.
return maskObject(value as Record<string, unknown>, depth + 1);
}
// Non-string primitives (number/boolean/bigint/symbol) at sensitive keys
// are passed through unchanged. The original contract was "only mask
// strings" — a number at an `email` key is a type error upstream and
// shouldn't be silently replaced with `***`.
return value;
}
function maskObject(data: Record<string, unknown>, depth: number): Record<string, unknown> {
if (depth > 4) return { _truncated: '[depth-limit]' };
const masked: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (isSensitiveKey(key)) {
masked[key] = maskValue(value, depth + 1);
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
masked[key] = maskObject(value as Record<string, unknown>, depth + 1);
} else if (Array.isArray(value)) {
masked[key] = value.map((v) =>
v && typeof v === 'object' && !Array.isArray(v)
? maskObject(v as Record<string, unknown>, depth + 1)
: v,
);
} else {
masked[key] = value;
}
}
return masked;
}
/** /**
* Masks sensitive field values to prevent PII or secrets from being stored * Masks sensitive field values to prevent PII or secrets from being stored
* verbatim in the audit log (SECURITY-GUIDELINES.md §5.2). * verbatim in the audit log (SECURITY-GUIDELINES.md §5.2).
* *
* Strings are replaced with a partial mask - first 2 chars + *** + last 2 chars. * Strings are replaced with a partial mask - first 2 chars + *** + last 2 chars.
* Walks nested objects/arrays so e.g. `{recipient: {email: "a@b"}}` masks
* the inner value too.
*/ */
export function maskSensitiveFields( export function maskSensitiveFields(
data?: Record<string, unknown>, data?: Record<string, unknown>,
): Record<string, unknown> | undefined { ): Record<string, unknown> | undefined {
if (!data) return undefined; if (!data) return undefined;
const masked = { ...data }; return maskObject(data, 0);
for (const key of Object.keys(masked)) {
if (SENSITIVE_FIELDS.has(key) && typeof masked[key] === 'string') {
const val = masked[key] as string;
masked[key] = val.length > 4 ? `${val.slice(0, 2)}***${val.slice(-2)}` : '***';
}
}
return masked;
} }
/** /**

View File

@@ -195,6 +195,36 @@ function humanizeEnum(raw: string): string {
return raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); return raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
} }
/**
* Format an arbitrary enum-shaped string ("hot_lead" → "Hot Lead",
* "in_progress" → "In Progress"). Centralised so list columns, badge
* components, and detail pages render the same value consistently —
* replaces the scattered ad-hoc `.replace(/_/g, ' ')` calls flagged
* by ui-ux-auditor H1.
*/
export function formatEnum(value: string | null | undefined): string {
if (!value) return '';
return humanizeEnum(value);
}
/** Format a pipeline stage value. Falls back to formatEnum for unknown values. */
export function formatStage(value: string | null | undefined): string {
if (!value) return '';
return STAGE_LABELS[safeStage(value)] ?? formatEnum(value);
}
/** Format a generic status (eoi_status, contract_status, deposit_status,
* invoice status, document status). Same shape as the enum but kept as
* a separate exported alias so call sites read intentionally. */
export function formatStatus(value: string | null | undefined): string {
return formatEnum(value);
}
/** Format a priority enum ('low' | 'medium' | 'high' | 'urgent'). */
export function formatPriority(value: string | null | undefined): string {
return formatEnum(value);
}
export function toSelectOptions<T extends readonly string[]>( export function toSelectOptions<T extends readonly string[]>(
values: T, values: T,
): Array<{ value: T[number]; label: string }> { ): Array<{ value: T[number]; label: string }> {

View File

@@ -0,0 +1,25 @@
-- 0057_search_fts_indexes.sql
-- ----------------------------------------------------------------------------
-- Backing GIN indexes for the to_tsvector expressions used by
-- src/lib/services/search.service.ts. Without these the full-text
-- predicates (`to_tsvector('simple', col) @@ to_tsquery('simple', $1)`)
-- sequential-scan every row in clients / yachts / companies / interests
-- on each search, which becomes painful at scale.
--
-- Built with CREATE INDEX CONCURRENTLY so the index build doesn't lock
-- the table for new writes. That means each statement must run OUTSIDE
-- a transaction — the custom `scripts/db-migrate.ts` runner detects
-- CONCURRENTLY and runs the statement standalone.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_clients_fulltext
ON clients USING gin (to_tsvector('simple', coalesce(full_name, '')));
--> statement-breakpoint
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_residential_clients_fulltext
ON residential_clients USING gin (to_tsvector('simple', coalesce(full_name, '')));
--> statement-breakpoint
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_yachts_fulltext
ON yachts USING gin (to_tsvector('simple', coalesce(name, '') || ' ' || coalesce(builder, '')));
-- companies search uses plain ILIKE only (no to_tsvector); index omitted.

View File

@@ -302,7 +302,9 @@ export const userPermissionOverrides = pgTable(
id: text('id') id: text('id')
.primaryKey() .primaryKey()
.$defaultFn(() => crypto.randomUUID()), .$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull(), userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
portId: text('port_id') portId: text('port_id')
.notNull() .notNull()
.references(() => ports.id, { onDelete: 'cascade' }), .references(() => ports.id, { onDelete: 'cascade' }),
@@ -384,7 +386,9 @@ export const userPortRoles = pgTable(
id: text('id') id: text('id')
.primaryKey() .primaryKey()
.$defaultFn(() => crypto.randomUUID()), .$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull(), // references Better Auth user ID userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
portId: text('port_id') portId: text('port_id')
.notNull() .notNull()
.references(() => ports.id, { onDelete: 'cascade' }), .references(() => ports.id, { onDelete: 'cascade' }),

View File

@@ -13,6 +13,10 @@ interface AdminEmailChangeData {
interface RenderOpts { interface RenderOpts {
branding?: BrandingShell | null; branding?: BrandingShell | null;
/** Admin override for the email subject. Falls back to the template's
* default when null/empty. Wired through from email_templates.subject
* via getTemplateOverridesForKey() in mailer-config.ts. */
subject?: string | null;
} }
function AdminEmailChangeBody({ function AdminEmailChangeBody({
@@ -78,7 +82,9 @@ export async function adminEmailChangeEmail(
overrides?: RenderOpts, overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> { ): Promise<{ subject: string; html: string; text: string }> {
const portName = data.portName ?? 'Port Nimara'; const portName = data.portName ?? 'Port Nimara';
const subject = `An administrator updated your ${portName} sign-in email`; const subject = overrides?.subject?.trim()
? overrides.subject
: `An administrator updated your ${portName} sign-in email`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const body = await render( const body = await render(

View File

@@ -15,6 +15,7 @@ interface InviteData {
interface RenderOpts { interface RenderOpts {
branding?: BrandingShell | null; branding?: BrandingShell | null;
subject?: string | null;
} }
function InviteBody({ function InviteBody({
@@ -85,7 +86,9 @@ export async function crmInviteEmail(
overrides?: RenderOpts, overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> { ): Promise<{ subject: string; html: string; text: string }> {
const portName = data.portName ?? 'Port Nimara'; const portName = data.portName ?? 'Port Nimara';
const subject = `You're invited to the ${portName} CRM`; const subject = overrides?.subject?.trim()
? overrides.subject
: `You're invited to the ${portName} CRM`;
const role = data.isSuperAdmin ? 'super administrator' : 'administrator'; const role = data.isSuperAdmin ? 'super administrator' : 'administrator';
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);

View File

@@ -12,6 +12,7 @@ export interface InquiryClientConfirmationData {
interface RenderOpts { interface RenderOpts {
branding?: BrandingShell | null; branding?: BrandingShell | null;
subject?: string | null;
} }
function ClientConfirmationBody({ function ClientConfirmationBody({
@@ -61,9 +62,11 @@ export async function inquiryClientConfirmation(
const { firstName, mooringNumber, contactEmail } = data; const { firstName, mooringNumber, contactEmail } = data;
const portName = data.portName ?? 'Port Nimara'; const portName = data.portName ?? 'Port Nimara';
const berthText = mooringNumber ? `Berth ${mooringNumber}` : `a ${portName} Berth`; const berthText = mooringNumber ? `Berth ${mooringNumber}` : `a ${portName} Berth`;
const subject = mooringNumber const subject = overrides?.subject?.trim()
? `Thank You for Your Interest in Berth ${mooringNumber}` ? overrides.subject
: `Thank You for Your Interest in a ${portName} Berth`; : mooringNumber
? `Thank You for Your Interest in Berth ${mooringNumber}`
: `Thank You for Your Interest in a ${portName} Berth`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const body = await render( const body = await render(

View File

@@ -14,6 +14,7 @@ export interface InquirySalesNotificationData {
interface RenderOpts { interface RenderOpts {
branding?: BrandingShell | null; branding?: BrandingShell | null;
subject?: string | null;
} }
function SalesNotificationBody({ function SalesNotificationBody({
@@ -75,7 +76,7 @@ export async function inquirySalesNotification(
) { ) {
const portName = data.portName ?? 'Port Nimara'; const portName = data.portName ?? 'Port Nimara';
const mooringDisplay = data.mooringNumber || 'None'; const mooringDisplay = data.mooringNumber || 'None';
const subject = `New Interest - ${portName}`; const subject = overrides?.subject?.trim() ? overrides.subject : `New Interest - ${portName}`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const body = await render( const body = await render(

View File

@@ -19,6 +19,7 @@ interface DigestData {
interface RenderOpts { interface RenderOpts {
branding?: BrandingShell | null; branding?: BrandingShell | null;
subject?: string | null;
} }
const TYPE_LABELS: Record<string, string> = { const TYPE_LABELS: Record<string, string> = {
@@ -117,7 +118,9 @@ export async function notificationDigestEmail(
data: DigestData, data: DigestData,
overrides?: RenderOpts, overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> { ): Promise<{ subject: string; html: string; text: string }> {
const subject = `${data.portName} CRM digest — ${data.totalUnread} unread`; const subject = overrides?.subject?.trim()
? overrides.subject
: `${data.portName} CRM digest — ${data.totalUnread} unread`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(<DigestBody {...data} accent={accent} />, { pretty: false }); const body = await render(<DigestBody {...data} accent={accent} />, { pretty: false });

View File

@@ -5,6 +5,7 @@ import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '
interface RenderOpts { interface RenderOpts {
branding?: BrandingShell | null; branding?: BrandingShell | null;
subject?: string | null;
} }
export interface ResidentialClientConfirmationData { export interface ResidentialClientConfirmationData {
@@ -60,7 +61,9 @@ export async function residentialClientConfirmation(
overrides?: RenderOpts, overrides?: RenderOpts,
) { ) {
const portName = data.portName ?? 'Port Nimara'; const portName = data.portName ?? 'Port Nimara';
const subject = `Thank You for Your Interest - ${portName} Residences`; const subject = overrides?.subject?.trim()
? overrides.subject
: `Thank You for Your Interest - ${portName} Residences`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const body = await render( const body = await render(
<ClientConfirmationBody <ClientConfirmationBody
@@ -178,7 +181,9 @@ export async function residentialSalesAlert(
overrides?: RenderOpts, overrides?: RenderOpts,
) { ) {
const portName = data.portName ?? 'Port Nimara'; const portName = data.portName ?? 'Port Nimara';
const subject = `New Residential Inquiry - ${data.fullName}`; const subject = overrides?.subject?.trim()
? overrides.subject
: `New Residential Inquiry - ${data.fullName}`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, { const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
pretty: false, pretty: false,

View File

@@ -44,6 +44,7 @@ const RECURRING_JOB_NAMES: ReadonlySet<string> = new Set([
'gdpr-export-cleanup', 'gdpr-export-cleanup',
'ai-usage-retention', 'ai-usage-retention',
'error-events-retention', 'error-events-retention',
'audit-logs-retention',
'website-submissions-retention', 'website-submissions-retention',
]); ]);

View File

@@ -59,6 +59,10 @@ export async function registerRecurringJobs(): Promise<void> {
{ queue: 'maintenance', name: 'ai-usage-retention', pattern: '0 5 * * *' }, { queue: 'maintenance', name: 'ai-usage-retention', pattern: '0 5 * * *' },
// Migration 0040 contract: error_events older than 90 days get pruned. // Migration 0040 contract: error_events older than 90 days get pruned.
{ queue: 'maintenance', name: 'error-events-retention', pattern: '0 6 * * *' }, { queue: 'maintenance', name: 'error-events-retention', pattern: '0 6 * * *' },
// 90-day retention for audit_logs — mirrors error_events. Metadata
// is masked at insert time but old rows still represent stale PII
// exposure that has no operational value past the window.
{ queue: 'maintenance', name: 'audit-logs-retention', pattern: '15 6 * * *' },
// Raw website inquiry payloads — 180-day retention. // Raw website inquiry payloads — 180-day retention.
{ queue: 'maintenance', name: 'website-submissions-retention', pattern: '0 7 * * *' }, { queue: 'maintenance', name: 'website-submissions-retention', pattern: '0 7 * * *' },
]; ];

View File

@@ -7,7 +7,7 @@ import { db } from '@/lib/db';
import { formSubmissions } from '@/lib/db/schema/documents'; import { formSubmissions } from '@/lib/db/schema/documents';
import { gdprExports } from '@/lib/db/schema/gdpr'; import { gdprExports } from '@/lib/db/schema/gdpr';
import { aiUsageLedger } from '@/lib/db/schema/ai-usage'; import { aiUsageLedger } from '@/lib/db/schema/ai-usage';
import { errorEvents } from '@/lib/db/schema/system'; import { auditLogs, errorEvents } from '@/lib/db/schema/system';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions'; import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { attachWorkerAudit } from '@/lib/queue/audit-helpers'; import { attachWorkerAudit } from '@/lib/queue/audit-helpers';
@@ -19,6 +19,10 @@ const AI_USAGE_RETENTION_DAYS = 90;
/** error_events rows older than this are pruned. Migration 0040 declares /** error_events rows older than this are pruned. Migration 0040 declares
* this contract; the worker had no implementation until now. */ * this contract; the worker had no implementation until now. */
const ERROR_EVENTS_RETENTION_DAYS = 90; const ERROR_EVENTS_RETENTION_DAYS = 90;
/** audit_logs rows older than this are pruned. Mirrors error_events.
* Metadata is masked at insert time but older rows have no operational
* value past the window and represent residual stale-PII exposure. */
const AUDIT_LOGS_RETENTION_DAYS = 90;
/** Raw website inquiry payloads (website_submissions) — kept long enough /** Raw website inquiry payloads (website_submissions) — kept long enough
* to investigate "why didn't this lead reach the CRM" inbound questions * to investigate "why didn't this lead reach the CRM" inbound questions
* but not indefinitely. 180d aligns with the typical sales cycle. */ * but not indefinitely. 180d aligns with the typical sales cycle. */
@@ -139,6 +143,18 @@ export const maintenanceWorker = new Worker(
); );
break; break;
} }
case 'audit-logs-retention': {
const cutoff = new Date(Date.now() - AUDIT_LOGS_RETENTION_DAYS * 24 * 60 * 60 * 1000);
const result = await db
.delete(auditLogs)
.where(lt(auditLogs.createdAt, cutoff))
.returning({ id: auditLogs.id });
logger.info(
{ deleted: result.length, retentionDays: AUDIT_LOGS_RETENTION_DAYS },
'Audit logs retention sweep complete',
);
break;
}
case 'website-submissions-retention': { case 'website-submissions-retention': {
// Raw inquiry payloads from the marketing-site dual-write. Keep // Raw inquiry payloads from the marketing-site dual-write. Keep
// long enough to debug capture issues but not forever — these // long enough to debug capture issues but not forever — these

View File

@@ -5,6 +5,18 @@ import type { ConnectionOptions } from 'bullmq';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { attachWorkerAudit } from '@/lib/queue/audit-helpers'; import { attachWorkerAudit } from '@/lib/queue/audit-helpers';
import { QUEUE_CONFIGS } from '@/lib/queue'; import { QUEUE_CONFIGS } from '@/lib/queue';
import { safeUrl } from '@/lib/email/shell';
/** HTML-escape user-supplied text so notification.description / .title
* can't break out of the surrounding `<p>` tag or smuggle <script>. */
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export const notificationsWorker = new Worker( export const notificationsWorker = new Worker(
'notifications', 'notifications',
@@ -62,12 +74,19 @@ export const notificationsWorker = new Worker(
.limit(1); .limit(1);
if (!authUser?.email) break; if (!authUser?.email) break;
// Subject is set as plain text (not HTML) so escaping isn't
// needed there, but the body interpolates `notif.description`
// and `notif.link` into HTML — both attacker-influenceable via
// any service that enqueues a notification (e.g. document title
// copied from user-supplied filename, reminder note text).
const bodyText = escapeHtml(notif.description ?? notif.title);
const linkHtml = notif.link
? `<p><a href="${safeUrl(`${process.env.APP_URL ?? ''}${notif.link}`)}">View in CRM</a></p>`
: '';
await sendEmail( await sendEmail(
authUser.email, authUser.email,
`[Port Nimara] ${notif.title}`, `[Port Nimara] ${notif.title}`,
`<p>${notif.description ?? notif.title}</p>${ `<p>${bodyText}</p>${linkHtml}`,
notif.link ? `<p><a href="${process.env.APP_URL}${notif.link}">View in CRM</a></p>` : ''
}`,
); );
await db await db

View File

@@ -29,10 +29,10 @@
import { timingSafeEqual } from 'node:crypto'; import { timingSafeEqual } from 'node:crypto';
import { and, eq } from 'drizzle-orm'; import { and, eq, inArray, sql } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { clients, clientMergeLog } from '@/lib/db/schema/clients'; import { clients, clientContacts, clientMergeLog } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests'; import { interests } from '@/lib/db/schema/interests';
import { berthReservations } from '@/lib/db/schema/reservations'; import { berthReservations } from '@/lib/db/schema/reservations';
import { files, documents, formSubmissions } from '@/lib/db/schema/documents'; import { files, documents, formSubmissions } from '@/lib/db/schema/documents';
@@ -40,6 +40,7 @@ import { documentSends } from '@/lib/db/schema/brochures';
import { emailThreads } from '@/lib/db/schema/email'; import { emailThreads } from '@/lib/db/schema/email';
import { reminders } from '@/lib/db/schema/operations'; import { reminders } from '@/lib/db/schema/operations';
import { scratchpadNotes } from '@/lib/db/schema/system'; import { scratchpadNotes } from '@/lib/db/schema/system';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { user as authUser } from '@/lib/db/schema/users'; import { user as authUser } from '@/lib/db/schema/users';
import { redis } from '@/lib/redis'; import { redis } from '@/lib/redis';
import { sendEmail } from '@/lib/email'; import { sendEmail } from '@/lib/email';
@@ -189,6 +190,29 @@ export async function hardDeleteClient(args: {
if (!locked) throw new NotFoundError('client'); if (!locked) throw new NotFoundError('client');
if (!locked.archivedAt) throw new ConflictError('Client must be archived'); if (!locked.archivedAt) throw new ConflictError('Client must be archived');
// Read email contacts BEFORE the cascade so we can wipe matching
// website_submissions rows — that table has no clientId FK (raw
// inquiry-form data, pre-promotion), matched only by email in the
// JSONB payload. Article-17 requires removing the data subject's
// submitted form data too.
const emailContactRows = await tx
.select({ value: clientContacts.value })
.from(clientContacts)
.where(and(eq(clientContacts.clientId, args.clientId), eq(clientContacts.channel, 'email')));
const emailValues = emailContactRows
.map((r) => r.value.trim().toLowerCase())
.filter((v) => v.length > 0);
if (emailValues.length > 0) {
await tx
.delete(websiteSubmissions)
.where(
and(
eq(websiteSubmissions.portId, args.portId),
inArray(sql<string>`LOWER(${websiteSubmissions.payload}->>'email')`, emailValues),
),
);
}
// Detach nullable FKs so we keep their audit history. // Detach nullable FKs so we keep their audit history.
await tx.update(files).set({ clientId: null }).where(eq(files.clientId, args.clientId)); await tx.update(files).set({ clientId: null }).where(eq(files.clientId, args.clientId));
await tx.update(documents).set({ clientId: null }).where(eq(documents.clientId, args.clientId)); await tx.update(documents).set({ clientId: null }).where(eq(documents.clientId, args.clientId));
@@ -265,7 +289,7 @@ function hashIds(ids: string[]): string {
// Stable hash so the same set always produces the same key — order // Stable hash so the same set always produces the same key — order
// independent. SHA-1 is more than enough for collision-avoidance on // independent. SHA-1 is more than enough for collision-avoidance on
// a per-user keyspace. // a per-user keyspace.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { createHash } = require('node:crypto') as typeof import('node:crypto'); const { createHash } = require('node:crypto') as typeof import('node:crypto');
const sorted = [...ids].sort().join('|'); const sorted = [...ids].sort().join('|');
return createHash('sha1').update(sorted).digest('hex'); return createHash('sha1').update(sorted).digest('hex');

View File

@@ -25,43 +25,52 @@ const BODY_MAX_BYTES = 1 * 1024;
// A 5xx in /api/v1/clients (create / update) was landing full client // A 5xx in /api/v1/clients (create / update) was landing full client
// PII (full name, DOB, address, phone, nationality, email) in // PII (full name, DOB, address, phone, nationality, email) in
// error_events.request_body_excerpt for the super-admin inspector. // error_events.request_body_excerpt for the super-admin inspector.
// Extend to cover GDPR-relevant fields too. // Match fragments case-insensitively + snake/kebab-normalized so the
const SENSITIVE_KEYS = new Set([ // redactor catches `recipientEmail`, `client_email`, `phone_number`,
// `tax_id`, `passwordHash`, etc. without an exhaustive enumeration.
const SENSITIVE_KEY_FRAGMENTS = [
// Credentials // Credentials
'password', 'password',
'newPassword',
'oldPassword',
'token', 'token',
'secret', 'secret',
'apiKey', 'api_key',
'accessKey', 'apikey',
'secretKey', 'access_key',
'creditCard', 'secret_key',
'cardNumber', 'credit_card',
'card_number',
'cvv', 'cvv',
'ssn', 'ssn',
'authorization', 'authorization',
'cookie',
// PII // PII
'email', 'email',
'emails',
'phone', 'phone',
'phoneNumber',
'mobile', 'mobile',
'whatsapp', 'whatsapp',
'dob', 'dob',
'dateOfBirth', 'date_of_birth',
'birthdate', 'birthdate',
'address', 'address',
'street', 'street',
'postcode', 'postcode',
'zip', 'zip',
'nationalId', 'national_id',
'passport', 'passport',
'taxId', 'iban',
'fullName', 'tax_id',
'firstName', 'taxid',
'lastName', 'full_name',
]); 'fullname',
'first_name',
'last_name',
'recipient',
];
function isSensitiveKey(key: string): boolean {
const k = key.toLowerCase().replace(/[-]/g, '_');
return SENSITIVE_KEY_FRAGMENTS.some((frag) => k.includes(frag));
}
/** Drop sensitive keys + cap the JSON length. */ /** Drop sensitive keys + cap the JSON length. */
function sanitizeBody(body: unknown): string | null { function sanitizeBody(body: unknown): string | null {
@@ -77,7 +86,7 @@ function sanitizeBody(body: unknown): string | null {
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) { for (const [k, v] of Object.entries(value)) {
if (SENSITIVE_KEYS.has(k)) { if (isSensitiveKey(k)) {
out[k] = '[REDACTED]'; out[k] = '[REDACTED]';
} else { } else {
out[k] = walk(v); out[k] = walk(v);

View File

@@ -9,7 +9,7 @@
* yacht ownership rows that resolve to this client. * yacht ownership rows that resolve to this client.
*/ */
import { and, eq, or } from 'drizzle-orm'; import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { NotFoundError } from '@/lib/errors'; import { NotFoundError } from '@/lib/errors';
@@ -20,15 +20,21 @@ import {
clientNotes, clientNotes,
clientRelationships, clientRelationships,
clientTags, clientTags,
clientMergeLog,
} from '@/lib/db/schema/clients'; } from '@/lib/db/schema/clients';
import { tags } from '@/lib/db/schema/system'; import { tags, scratchpadNotes } from '@/lib/db/schema/system';
import { companies, companyMemberships } from '@/lib/db/schema/companies'; import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts'; import { yachts } from '@/lib/db/schema/yachts';
import { interests } from '@/lib/db/schema/interests'; import { interests } from '@/lib/db/schema/interests';
import { berthReservations } from '@/lib/db/schema/reservations'; import { berthReservations } from '@/lib/db/schema/reservations';
import { invoices } from '@/lib/db/schema/financial'; import { invoices } from '@/lib/db/schema/financial';
import { documents } from '@/lib/db/schema/documents'; import { documents, files, formSubmissions } from '@/lib/db/schema/documents';
import { auditLogs } from '@/lib/db/schema/system'; import { auditLogs } from '@/lib/db/schema/system';
import { portalUsers } from '@/lib/db/schema/portal';
import { emailThreads, emailMessages } from '@/lib/db/schema/email';
import { reminders, interestContactLog } from '@/lib/db/schema/operations';
import { documentSends } from '@/lib/db/schema/brochures';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
export interface GdprBundle { export interface GdprBundle {
/** Bundle metadata for traceability. */ /** Bundle metadata for traceability. */
@@ -50,9 +56,22 @@ export interface GdprBundle {
company: Record<string, unknown>; company: Record<string, unknown>;
}>; }>;
interests: Record<string, unknown>[]; interests: Record<string, unknown>[];
contactLog: Record<string, unknown>[];
reservations: Record<string, unknown>[]; reservations: Record<string, unknown>[];
invoices: Record<string, unknown>[]; invoices: Record<string, unknown>[];
documents: Record<string, unknown>[]; documents: Record<string, unknown>[];
files: Record<string, unknown>[];
formSubmissions: Record<string, unknown>[];
websiteSubmissions: Record<string, unknown>[];
documentSends: Record<string, unknown>[];
emailThreads: Array<{
thread: Record<string, unknown>;
messages: Record<string, unknown>[];
}>;
reminders: Record<string, unknown>[];
scratchpadNotes: Record<string, unknown>[];
portalUsers: Record<string, unknown>[];
mergeLog: Record<string, unknown>[];
auditTrail: Record<string, unknown>[]; auditTrail: Record<string, unknown>[];
} }
@@ -79,6 +98,14 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
reservationRows, reservationRows,
invoiceRows, invoiceRows,
documentRows, documentRows,
fileRows,
formSubmissionRows,
documentSendRows,
threadRows,
reminderRows,
scratchpadRows,
portalUserRows,
mergeLogRows,
auditRows, auditRows,
] = await Promise.all([ ] = await Promise.all([
db.query.clientContacts.findMany({ where: eq(clientContacts.clientId, clientId) }), db.query.clientContacts.findMany({ where: eq(clientContacts.clientId, clientId) }),
@@ -127,6 +154,34 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
db.query.documents.findMany({ db.query.documents.findMany({
where: and(eq(documents.portId, portId), eq(documents.clientId, clientId)), where: and(eq(documents.portId, portId), eq(documents.clientId, clientId)),
}), }),
db.query.files.findMany({
where: and(eq(files.portId, portId), eq(files.clientId, clientId)),
}),
db.query.formSubmissions.findMany({
where: eq(formSubmissions.clientId, clientId),
}),
db.query.documentSends.findMany({
where: and(eq(documentSends.portId, portId), eq(documentSends.clientId, clientId)),
}),
db.query.emailThreads.findMany({
where: and(eq(emailThreads.portId, portId), eq(emailThreads.clientId, clientId)),
}),
db.query.reminders.findMany({
where: and(eq(reminders.portId, portId), eq(reminders.clientId, clientId)),
}),
db.query.scratchpadNotes.findMany({
where: eq(scratchpadNotes.linkedClientId, clientId),
}),
db.query.portalUsers.findMany({ where: eq(portalUsers.clientId, clientId) }),
db.query.clientMergeLog.findMany({
where: and(
eq(clientMergeLog.portId, portId),
or(
eq(clientMergeLog.survivingClientId, clientId),
eq(clientMergeLog.mergedClientId, clientId),
),
),
}),
db.query.auditLogs.findMany({ db.query.auditLogs.findMany({
where: and( where: and(
eq(auditLogs.portId, portId), eq(auditLogs.portId, portId),
@@ -138,6 +193,56 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
}), }),
]); ]);
// Email messages are linked through threads (no direct clientId column).
const threadIds = threadRows.map((t) => t.id);
const messageRows = threadIds.length
? await db.query.emailMessages.findMany({
where: inArray(emailMessages.threadId, threadIds),
orderBy: (t, { asc }) => [asc(t.sentAt)],
})
: [];
const messagesByThread = new Map<string, Record<string, unknown>[]>();
for (const m of messageRows) {
const list = messagesByThread.get(m.threadId) ?? [];
list.push(toJsonRow(m));
messagesByThread.set(m.threadId, list);
}
const emailThreadBundle = threadRows.map((t) => ({
thread: toJsonRow(t),
messages: messagesByThread.get(t.id) ?? [],
}));
// Interest contact-log has no clientId — fetch via the client's interests.
const interestIds = interestRows.map((i) => i.id);
const contactLogRows = interestIds.length
? await db.query.interestContactLog.findMany({
where: and(
eq(interestContactLog.portId, portId),
inArray(interestContactLog.interestId, interestIds),
),
orderBy: (t, { desc }) => [desc(t.occurredAt)],
})
: [];
// Website submissions pre-date the client record (no FK). Match by any
// of the client's email contacts against payload->>'email' (case-
// insensitive) so the bundle includes inquiry forms that became this
// client.
const emailValues = contacts
.filter((c) => c.channel === 'email' && c.value)
.map((c) => c.value.toLowerCase());
const websiteSubmissionRows = emailValues.length
? await db
.select()
.from(websiteSubmissions)
.where(
and(
eq(websiteSubmissions.portId, portId),
inArray(sql<string>`LOWER(${websiteSubmissions.payload}->>'email')`, emailValues),
),
)
: [];
return { return {
meta: { meta: {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
@@ -162,9 +267,19 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
company: toJsonRow(row.company), company: toJsonRow(row.company),
})), })),
interests: interestRows.map(toJsonRow), interests: interestRows.map(toJsonRow),
contactLog: contactLogRows.map(toJsonRow),
reservations: reservationRows.map(toJsonRow), reservations: reservationRows.map(toJsonRow),
invoices: invoiceRows.map(toJsonRow), invoices: invoiceRows.map(toJsonRow),
documents: documentRows.map(toJsonRow), documents: documentRows.map(toJsonRow),
files: fileRows.map(toJsonRow),
formSubmissions: formSubmissionRows.map(toJsonRow),
websiteSubmissions: websiteSubmissionRows.map(toJsonRow),
documentSends: documentSendRows.map(toJsonRow),
emailThreads: emailThreadBundle,
reminders: reminderRows.map(toJsonRow),
scratchpadNotes: scratchpadRows.map(toJsonRow),
portalUsers: portalUserRows.map(toJsonRow),
mergeLog: mergeLogRows.map(toJsonRow),
auditTrail: auditRows.map(toJsonRow), auditTrail: auditRows.map(toJsonRow),
}; };
} }
@@ -245,9 +360,29 @@ export function renderBundleHtml(bundle: GdprBundle): string {
})), })),
), ),
tableSection('Interests', bundle.interests), tableSection('Interests', bundle.interests),
tableSection('Contact log', bundle.contactLog),
tableSection('Reservations', bundle.reservations), tableSection('Reservations', bundle.reservations),
tableSection('Invoices', bundle.invoices), tableSection('Invoices', bundle.invoices),
tableSection('Documents', bundle.documents), tableSection('Documents', bundle.documents),
tableSection('Files', bundle.files),
tableSection('Form submissions', bundle.formSubmissions),
tableSection('Website submissions (inquiry forms)', bundle.websiteSubmissions),
tableSection('Document sends (PDFs / brochures emailed)', bundle.documentSends),
tableSection(
'Email threads',
bundle.emailThreads.map((t) => ({
...t.thread,
messageCount: t.messages.length,
})),
),
tableSection(
'Email messages',
bundle.emailThreads.flatMap((t) => t.messages.map((m) => ({ threadId: t.thread.id, ...m }))),
),
tableSection('Reminders', bundle.reminders),
tableSection('Scratchpad notes', bundle.scratchpadNotes),
tableSection('Portal users', bundle.portalUsers),
tableSection('Merge log', bundle.mergeLog),
tableSection('Audit trail (last 500 events)', bundle.auditTrail), tableSection('Audit trail (last 500 events)', bundle.auditTrail),
].join('\n'); ].join('\n');

View File

@@ -17,8 +17,9 @@ export const SETTING_KEYS = {
emailFromName: 'email_from_name', emailFromName: 'email_from_name',
emailFromAddress: 'email_from_address', emailFromAddress: 'email_from_address',
emailReplyTo: 'email_reply_to', emailReplyTo: 'email_reply_to',
emailSignatureHtml: 'email_signature_html', // email_signature_html / email_footer_html — removed; the email shell
emailFooterHtml: 'email_footer_html', // reads branding_email_header_html / branding_email_footer_html from
// /admin/branding, which is the source of truth.
emailAllowPersonalAccountSends: 'email_allow_personal_account_sends', emailAllowPersonalAccountSends: 'email_allow_personal_account_sends',
smtpHostOverride: 'smtp_host_override', smtpHostOverride: 'smtp_host_override',
smtpPortOverride: 'smtp_port_override', smtpPortOverride: 'smtp_port_override',
@@ -120,8 +121,6 @@ export interface PortEmailConfig {
fromName: string; fromName: string;
fromAddress: string; fromAddress: string;
replyTo: string | null; replyTo: string | null;
signatureHtml: string | null;
footerHtml: string | null;
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: number;
smtpUser: string | null; smtpUser: string | null;
@@ -139,8 +138,6 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
fromName, fromName,
fromAddress, fromAddress,
replyTo, replyTo,
signatureHtml,
footerHtml,
smtpHost, smtpHost,
smtpPort, smtpPort,
smtpUser, smtpUser,
@@ -150,8 +147,6 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
readSetting<string>(SETTING_KEYS.emailFromName, portId), readSetting<string>(SETTING_KEYS.emailFromName, portId),
readSetting<string>(SETTING_KEYS.emailFromAddress, portId), readSetting<string>(SETTING_KEYS.emailFromAddress, portId),
readSetting<string>(SETTING_KEYS.emailReplyTo, portId), readSetting<string>(SETTING_KEYS.emailReplyTo, portId),
readSetting<string>(SETTING_KEYS.emailSignatureHtml, portId),
readSetting<string>(SETTING_KEYS.emailFooterHtml, portId),
readSetting<string>(SETTING_KEYS.smtpHostOverride, portId), readSetting<string>(SETTING_KEYS.smtpHostOverride, portId),
readSetting<number>(SETTING_KEYS.smtpPortOverride, portId), readSetting<number>(SETTING_KEYS.smtpPortOverride, portId),
readSetting<string>(SETTING_KEYS.smtpUserOverride, portId), readSetting<string>(SETTING_KEYS.smtpUserOverride, portId),
@@ -176,8 +171,6 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
fromName: fromName ?? envFromName, fromName: fromName ?? envFromName,
fromAddress: fromAddress ?? envFromAddress, fromAddress: fromAddress ?? envFromAddress,
replyTo: replyTo ?? null, replyTo: replyTo ?? null,
signatureHtml: signatureHtml ?? null,
footerHtml: footerHtml ?? null,
smtpHost: smtpHost ?? env.SMTP_HOST, smtpHost: smtpHost ?? env.SMTP_HOST,
smtpPort: smtpPort ?? env.SMTP_PORT, smtpPort: smtpPort ?? env.SMTP_PORT,
smtpUser: smtpUser ?? env.SMTP_USER ?? null, smtpUser: smtpUser ?? env.SMTP_USER ?? null,

View File

@@ -59,6 +59,9 @@ function SocketProviderClient({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
if (!session?.user || !currentPortId) { if (!session?.user || !currentPortId) {
// Tear down the socket on session/port loss — canonical
// subscription-cleanup pattern.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSocket(null); setSocket(null);
setIsConnected(false); setIsConnected(false);
return; return;