Compare commits
28 Commits
7591231c47
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 352b2420b7 | |||
| 459c68a2c3 | |||
| adc9802361 | |||
| d8f739a7c2 | |||
| 93989b1e1d | |||
| 5b9560531e | |||
| f55be14813 | |||
| 6bc81270b9 | |||
| 38e392e38b | |||
| 039ef25fe5 | |||
| b3753b96a1 | |||
| 9147f2857e | |||
| 47778796ad | |||
| f7425d1231 | |||
| df8c26d1b3 | |||
| 91703bdb00 | |||
| 3165ec651f | |||
| 661187cc79 | |||
| 4dc0bdd8c4 | |||
| 7f04c765f4 | |||
| 4d018be800 | |||
| 95d7776bb6 | |||
| 0cc05f302f | |||
| 54554a0928 | |||
| 9879b82e5f | |||
| 08adb4aeea | |||
| 6c4490f653 | |||
| 13efe177a5 |
@@ -24,6 +24,28 @@ export default defineConfig({
|
|||||||
name: 'setup',
|
name: 'setup',
|
||||||
testMatch: /smoke\/global-setup\.ts/,
|
testMatch: /smoke\/global-setup\.ts/,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Permission-matrix UX sweep. Users + roles are seeded separately via
|
||||||
|
// `pnpm tsx tests/e2e/permissions/seed-permission-matrix.ts` (no global
|
||||||
|
// setup dependency — relies on the already-seeded dev DB).
|
||||||
|
name: 'permissions',
|
||||||
|
testMatch: /permissions\/.*\.spec\.ts/,
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Lean role × viewport access matrix. Users pre-seeded (admin/director/
|
||||||
|
// sales/viewer/residential_partner) — no global-setup dependency. Few
|
||||||
|
// route compilations, so it stays under the dev-server OOM threshold.
|
||||||
|
name: 'matrix',
|
||||||
|
testMatch: /matrix\/.*\.spec\.ts/,
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'smoke',
|
name: 'smoke',
|
||||||
testMatch: /smoke\/\d{2}-.*\.spec\.ts/,
|
testMatch: /smoke\/\d{2}-.*\.spec\.ts/,
|
||||||
|
|||||||
176
scripts/import-website-inquiries-from-nocodb.ts
Normal file
176
scripts/import-website-inquiries-from-nocodb.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* One-off import of historical "Website – Contact Form Submissions" from NocoDB
|
||||||
|
* into the CRM `website_submissions` table, so they show up in the Inquiries
|
||||||
|
* workbench alongside post-cutover submissions.
|
||||||
|
*
|
||||||
|
* The cutover migration imported interests / residential / berths / expenses but
|
||||||
|
* NOT the contact-form table — those general contact-page inquiries (the
|
||||||
|
* "broker"/"investor"/"owner" enquiries) were left behind in NocoDB.
|
||||||
|
*
|
||||||
|
* Idempotent: each row maps to a deterministic `submission_id`
|
||||||
|
* (`nocodb-cf-<id>`) guarded by the unique index, plus a `migration_source_links`
|
||||||
|
* ledger row (`source_system='nocodb_website_submissions'`). Re-running is a
|
||||||
|
* no-op for already-imported rows.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts # dry-run
|
||||||
|
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply # write
|
||||||
|
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply --port-slug port-nimara
|
||||||
|
*
|
||||||
|
* Requires NOCODB_URL + NOCODB_TOKEN in env (same as the migration). Writes to
|
||||||
|
* whatever DATABASE_URL points at — point it at prod ONLY with explicit approval.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db, closeDb } from '@/lib/db';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
||||||
|
import { migrationSourceLinks } from '@/lib/db/schema/migration';
|
||||||
|
import {
|
||||||
|
loadNocoDbConfig,
|
||||||
|
fetchAllRows,
|
||||||
|
NOCO_TABLES,
|
||||||
|
type NocoDbRow,
|
||||||
|
} from '@/lib/dedup/nocodb-source';
|
||||||
|
|
||||||
|
const SOURCE_SYSTEM = 'nocodb_website_submissions';
|
||||||
|
const APPLIED_ID = 'import-website-inquiries';
|
||||||
|
|
||||||
|
function arg(name: string): string | undefined {
|
||||||
|
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
|
||||||
|
if (hit) return hit.split('=')[1];
|
||||||
|
const idx = process.argv.indexOf(`--${name}`);
|
||||||
|
if (idx !== -1 && process.argv[idx + 1] && !process.argv[idx + 1]!.startsWith('--')) {
|
||||||
|
return process.argv[idx + 1];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(row: NocoDbRow, ...keys: string[]): string {
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = row[k];
|
||||||
|
if (typeof v === 'string' && v.trim()) return v.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(row: NocoDbRow): Date {
|
||||||
|
const raw = str(row, 'CreatedAt', 'created_at', 'Created At', 'createdAt');
|
||||||
|
if (raw) {
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (!Number.isNaN(d.getTime())) return d;
|
||||||
|
}
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
const portSlug = arg('port-slug') ?? 'port-nimara';
|
||||||
|
|
||||||
|
const [port] = await db
|
||||||
|
.select({ id: ports.id })
|
||||||
|
.from(ports)
|
||||||
|
.where(eq(ports.slug, portSlug))
|
||||||
|
.limit(1);
|
||||||
|
if (!port) throw new Error(`Unknown port slug: ${portSlug}`);
|
||||||
|
|
||||||
|
const config = loadNocoDbConfig();
|
||||||
|
console.log(`[import] Fetching contact-form submissions from NocoDB…`);
|
||||||
|
const rows = await fetchAllRows(NOCO_TABLES.websiteContactFormSubmissions, config);
|
||||||
|
console.log(`[import] Fetched ${rows.length} rows from NocoDB.`);
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const samples: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const legacyId = String(row.Id);
|
||||||
|
const submissionId = `nocodb-cf-${legacyId}`;
|
||||||
|
const fullName = str(row, 'Full Name', 'Name', 'full_name');
|
||||||
|
const email = str(row, 'Email Address', 'Email', 'email');
|
||||||
|
const interest = str(row, 'Type of Interest', 'interest');
|
||||||
|
const comments = str(row, 'Comments', 'comments');
|
||||||
|
const receivedAt = parseDate(row);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: fullName,
|
||||||
|
email,
|
||||||
|
interest,
|
||||||
|
comments,
|
||||||
|
imported_from: 'nocodb_contact_form',
|
||||||
|
legacy_nocodb_id: legacyId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (samples.length < 3) {
|
||||||
|
samples.push({
|
||||||
|
submissionId,
|
||||||
|
fullName,
|
||||||
|
email,
|
||||||
|
interest,
|
||||||
|
receivedAt: receivedAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apply) {
|
||||||
|
// Dry-run: count how many are not yet present.
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: websiteSubmissions.id })
|
||||||
|
.from(websiteSubmissions)
|
||||||
|
.where(eq(websiteSubmissions.submissionId, submissionId))
|
||||||
|
.limit(1);
|
||||||
|
if (existing) skipped += 1;
|
||||||
|
else inserted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.insert(websiteSubmissions)
|
||||||
|
.values({
|
||||||
|
portId: port.id,
|
||||||
|
submissionId,
|
||||||
|
kind: 'contact_form',
|
||||||
|
payload,
|
||||||
|
contactName: fullName || null,
|
||||||
|
contactEmail: email || null,
|
||||||
|
legacyNocodbId: legacyId,
|
||||||
|
receivedAt,
|
||||||
|
triageState: 'open',
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
|
||||||
|
.returning({ id: websiteSubmissions.id });
|
||||||
|
|
||||||
|
if (result[0]) {
|
||||||
|
inserted += 1;
|
||||||
|
await db
|
||||||
|
.insert(migrationSourceLinks)
|
||||||
|
.values({
|
||||||
|
sourceSystem: SOURCE_SYSTEM,
|
||||||
|
sourceId: legacyId,
|
||||||
|
targetEntityType: 'website_submission',
|
||||||
|
targetEntityId: result[0].id,
|
||||||
|
appliedId: APPLIED_ID,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
} else {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n[import] Sample rows:');
|
||||||
|
for (const s of samples) console.log(' ', JSON.stringify(s));
|
||||||
|
console.log(
|
||||||
|
`\n[import] ${apply ? 'APPLIED' : 'DRY-RUN'} — port=${portSlug}: ${inserted} ${
|
||||||
|
apply ? 'inserted' : 'would insert'
|
||||||
|
}, ${skipped} skipped (already present).`,
|
||||||
|
);
|
||||||
|
if (!apply) console.log('[import] Re-run with --apply to write these rows.');
|
||||||
|
|
||||||
|
await closeDb();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('[import] FAILED:', err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Route } from 'next';
|
import type { Route } from 'next';
|
||||||
import { AlertCircle, Anchor, FileSearch } from 'lucide-react';
|
import { AlertCircle, Anchor, FileSearch, BadgeDollarSign } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -33,6 +33,13 @@ export default async function BerthsAdminIndex({
|
|||||||
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
|
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
|
||||||
icon: FileSearch,
|
icon: FileSearch,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: `/${portSlug}/admin/berths/price-reconcile` as Route,
|
||||||
|
label: 'Price reconciliation',
|
||||||
|
description:
|
||||||
|
'Parse the purchase price from each berth’s current spec sheet and review old→new per berth. Approve per row or in bulk; nothing is written until you approve.',
|
||||||
|
icon: BadgeDollarSign,
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { BerthPriceReconcileTable } from '@/components/berths/berth-price-reconcile-table';
|
||||||
|
|
||||||
|
export default function BerthPriceReconcilePage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Berth price reconciliation"
|
||||||
|
eyebrow="ADMIN"
|
||||||
|
description="Prices parsed from each berth's current spec sheet, shown against the stored price. Review the changes and approve the ones you trust — nothing is written until you approve it."
|
||||||
|
/>
|
||||||
|
<BerthPriceReconcileTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-b
|
|||||||
import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
|
import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { WarningCallout } from '@/components/ui/warning-callout';
|
||||||
|
|
||||||
// All field arrays removed - every Documenso setting now flows through
|
// All field arrays removed - every Documenso setting now flows through
|
||||||
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
|
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
|
||||||
@@ -22,6 +23,35 @@ export default function DocumensoSettingsPage() {
|
|||||||
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
|
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WarningCallout title="Use Documenso v2, not v1 (v1 API is deprecated)">
|
||||||
|
<p>
|
||||||
|
The CRM's signing features are built for Documenso 2.x (v2). Set the API version
|
||||||
|
below to <strong>v1</strong> only if this port still points at a Documenso 1.13.x server.
|
||||||
|
Be aware these CRM functions <strong>do not work (or run degraded)</strong> on v1:
|
||||||
|
</p>
|
||||||
|
<ul className="ms-4 mt-1 list-disc space-y-1">
|
||||||
|
<li>
|
||||||
|
<strong>Editing an envelope after it is created</strong> (title, subject, redirect URL):
|
||||||
|
hard-fails, because v1 has no <code>/envelope/update</code> endpoint.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Upload-and-send contracts / reservations</strong> fall back to v1's
|
||||||
|
per-field placement: page size is assumed to be A4, and rich field metadata (required
|
||||||
|
flags, NUMBER min/max, CHECKBOX / DROPDOWN / RADIO option lists) is dropped.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>One-call send with per-recipient signing links</strong>,{' '}
|
||||||
|
<strong>sequential signing enforcement</strong>, and the{' '}
|
||||||
|
<strong>v2 webhook events</strong> (recipient viewed / signed, declined, reminder sent)
|
||||||
|
are unavailable or ignored on v1.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-1">
|
||||||
|
Recommended: upgrade the Documenso server to 2.x, then set the API version to v2 and run
|
||||||
|
the test-connection button to confirm.
|
||||||
|
</p>
|
||||||
|
</WarningCallout>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { InquiryInbox } from '@/components/admin/inquiry-inbox';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default function InquiriesPage() {
|
/**
|
||||||
return <InquiryInbox />;
|
* The inquiry inbox is now a top-level, permission-gated page at
|
||||||
|
* `/[portSlug]/inquiries` (resource `inquiries`), no longer admin-only.
|
||||||
|
* Redirect the legacy admin URL so old bookmarks/links still land.
|
||||||
|
*/
|
||||||
|
interface AdminInquiriesRedirectProps {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminInquiriesRedirect({ params }: AdminInquiriesRedirectProps) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
redirect(`/${portSlug}/inquiries`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ClientGroupDetail } from '@/components/client-groups/client-group-detail';
|
||||||
|
|
||||||
|
export default async function ClientGroupDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ portSlug: string; groupId: string }>;
|
||||||
|
}) {
|
||||||
|
const { groupId } = await params;
|
||||||
|
return <ClientGroupDetail groupId={groupId} />;
|
||||||
|
}
|
||||||
5
src/app/(dashboard)/[portSlug]/client-groups/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/client-groups/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ClientGroupsList } from '@/components/client-groups/client-groups-list';
|
||||||
|
|
||||||
|
export default function ClientGroupsPage() {
|
||||||
|
return <ClientGroupsList />;
|
||||||
|
}
|
||||||
11
src/app/(dashboard)/[portSlug]/inquiries/[id]/loading.tsx
Normal file
11
src/app/(dashboard)/[portSlug]/inquiries/[id]/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/app/(dashboard)/[portSlug]/inquiries/[id]/page.tsx
Normal file
10
src/app/(dashboard)/[portSlug]/inquiries/[id]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { InquiryDetail } from '@/components/inquiries/inquiry-detail';
|
||||||
|
|
||||||
|
interface InquiryDetailPageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function InquiryDetailPage({ params }: InquiryDetailPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
return <InquiryDetail id={id} />;
|
||||||
|
}
|
||||||
5
src/app/(dashboard)/[portSlug]/inquiries/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/inquiries/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { InquiryList } from '@/components/inquiries/inquiry-list';
|
||||||
|
|
||||||
|
export default function InquiriesPage() {
|
||||||
|
return <InquiryList />;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { ScanShell } from '@/components/scan/scan-shell';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||||
|
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Scan receipt',
|
title: 'Scan receipt',
|
||||||
@@ -14,5 +15,14 @@ export default async function ScanPage({ params }: { params: Promise<{ portSlug:
|
|||||||
const { portSlug } = await params;
|
const { portSlug } = await params;
|
||||||
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
||||||
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
|
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
|
||||||
return <ScanShell logoUrl={branding?.logoUrl ?? null} portName={port?.name ?? null} />;
|
// CM-6: manual-entry mode is resolved server-side so the client can skip
|
||||||
|
// on-device parsing entirely (no wasted Tesseract pass) and open an empty form.
|
||||||
|
const ocr = port ? await getResolvedOcrConfig(port.id).catch(() => null) : null;
|
||||||
|
return (
|
||||||
|
<ScanShell
|
||||||
|
logoUrl={branding?.logoUrl ?? null}
|
||||||
|
portName={port?.name ?? null}
|
||||||
|
manualEntry={ocr?.manualEntry ?? false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
autoPromoteWebsiteBerthInquiry,
|
autoPromoteWebsiteBerthInquiry,
|
||||||
isWebsiteBerthAutopromoteEnabled,
|
isWebsiteBerthAutopromoteEnabled,
|
||||||
} from '@/lib/services/website-intake-promote.service';
|
} from '@/lib/services/website-intake-promote.service';
|
||||||
|
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/public/website-inquiries
|
* POST /api/public/website-inquiries
|
||||||
@@ -149,6 +150,10 @@ export async function POST(req: NextRequest) {
|
|||||||
// hits, `returning()` yields zero rows and we look up the existing row to
|
// hits, `returning()` yields zero rows and we look up the existing row to
|
||||||
// return its id, mirroring the first-delivery shape so the website never
|
// return its id, mirroring the first-delivery shape so the website never
|
||||||
// sees a difference between fresh and dup.
|
// sees a difference between fresh and dup.
|
||||||
|
// Extract contact name/email into real columns so the inquiry list can
|
||||||
|
// search/sort/display without digging into the JSONB payload per row.
|
||||||
|
const fields = extractInquiryFields(parsed.payload);
|
||||||
|
|
||||||
const insertResult = await db
|
const insertResult = await db
|
||||||
.insert(websiteSubmissions)
|
.insert(websiteSubmissions)
|
||||||
.values({
|
.values({
|
||||||
@@ -157,6 +162,8 @@ export async function POST(req: NextRequest) {
|
|||||||
kind: parsed.kind,
|
kind: parsed.kind,
|
||||||
payload: parsed.payload,
|
payload: parsed.payload,
|
||||||
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
|
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
|
||||||
|
contactName: fields.fullName || null,
|
||||||
|
contactEmail: fields.email || null,
|
||||||
sourceIp: ip,
|
sourceIp: ip,
|
||||||
userAgent: req.headers.get('user-agent') ?? null,
|
userAgent: req.headers.get('user-agent') ?? null,
|
||||||
utmSource: parsed.utm_source ?? null,
|
utmSource: parsed.utm_source ?? null,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const saveSchema = z.object({
|
|||||||
clearApiKey: z.boolean().optional(),
|
clearApiKey: z.boolean().optional(),
|
||||||
useGlobal: z.boolean().optional(),
|
useGlobal: z.boolean().optional(),
|
||||||
aiEnabled: z.boolean().optional(),
|
aiEnabled: z.boolean().optional(),
|
||||||
|
manualEntry: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only role tiers that hold `admin.manage_settings` (director / super_admin)
|
// Only role tiers that hold `admin.manage_settings` (director / super_admin)
|
||||||
@@ -58,6 +59,7 @@ export const PUT = withAuth(
|
|||||||
clearApiKey: body.clearApiKey,
|
clearApiKey: body.clearApiKey,
|
||||||
useGlobal: body.useGlobal,
|
useGlobal: body.useGlobal,
|
||||||
aiEnabled: body.aiEnabled,
|
aiEnabled: body.aiEnabled,
|
||||||
|
manualEntry: body.manualEntry,
|
||||||
},
|
},
|
||||||
ctx.userId,
|
ctx.userId,
|
||||||
);
|
);
|
||||||
|
|||||||
23
src/app/api/v1/alerts/dismiss-all/route.ts
Normal file
23
src/app/api/v1/alerts/dismiss-all/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { ALERT_RULES } from '@/lib/db/schema/insights';
|
||||||
|
import { dismissAllForPort } from '@/lib/services/alerts.service';
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
ruleId: z.enum(ALERT_RULES).optional(),
|
||||||
|
severity: z.enum(['info', 'warning', 'critical']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = withAuth(async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const { ruleId, severity } = await parseBody(req, bodySchema);
|
||||||
|
const dismissed = await dismissAllForPort(ctx.portId, ctx.userId, { ruleId, severity });
|
||||||
|
return NextResponse.json({ data: { dismissed } });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -5,11 +5,13 @@ import { listAlertsForPort } from '@/lib/services/alerts.service';
|
|||||||
|
|
||||||
type AlertStatus = 'open' | 'dismissed' | 'resolved';
|
type AlertStatus = 'open' | 'dismissed' | 'resolved';
|
||||||
|
|
||||||
// Tier-4 (authz-auditor): alerts include permission_denied + audit-adjacent
|
// The alert feed is entirely operational/deal signals (stale interest, hot lead
|
||||||
// signals. Gated on admin.view_audit_log - same permission the audit log
|
// silent, EOI unsigned, signer overdue, reservation needs agreement, berth
|
||||||
// page uses.
|
// stalled, duplicate/unscanned expense) — there are no audit/security alert
|
||||||
|
// rules. Gated on interests.view so the operational roles that act on these
|
||||||
|
// (sales, director, viewer) see them; external residential partners don't.
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => {
|
withPermission('interests', 'view', async (req: NextRequest, ctx) => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;
|
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;
|
||||||
|
|
||||||
|
|||||||
36
src/app/api/v1/berths/price-reconcile/apply/handlers.ts
Normal file
36
src/app/api/v1/berths/price-reconcile/apply/handlers.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Route handler for `/api/v1/berths/price-reconcile/apply` (CM-2 Part A).
|
||||||
|
*
|
||||||
|
* Writes a rep-approved slice of parsed prices to the berths. In handlers.ts so
|
||||||
|
* integration tests can call it directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { applyBulkBerthPrices } from '@/lib/services/berth-price-reconcile.service';
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
approvals: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
berthId: z.string().min(1),
|
||||||
|
price: z.number().nonnegative(),
|
||||||
|
currency: z.string().min(1).max(8),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const postHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, bodySchema);
|
||||||
|
const result = await applyBulkBerthPrices(ctx.portId, body.approvals, ctx.userId);
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
5
src/app/api/v1/berths/price-reconcile/apply/route.ts
Normal file
5
src/app/api/v1/berths/price-reconcile/apply/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
|
import { postHandler } from './handlers';
|
||||||
|
|
||||||
|
export const POST = withAuth(withPermission('berths', 'edit', postHandler));
|
||||||
21
src/app/api/v1/berths/price-reconcile/handlers.ts
Normal file
21
src/app/api/v1/berths/price-reconcile/handlers.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Route handlers for `/api/v1/berths/price-reconcile` (CM-2 Part A).
|
||||||
|
*
|
||||||
|
* In handlers.ts so integration tests can call them directly, bypassing the
|
||||||
|
* auth/permission middleware (per CLAUDE.md "Route handler exports").
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { listPriceReconciliation } from '@/lib/services/berth-price-reconcile.service';
|
||||||
|
|
||||||
|
export const getHandler: RouteHandler = async (_req, ctx) => {
|
||||||
|
try {
|
||||||
|
const data = await listPriceReconciliation(ctx.portId);
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
5
src/app/api/v1/berths/price-reconcile/route.ts
Normal file
5
src/app/api/v1/berths/price-reconcile/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
|
import { getHandler } from './handlers';
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('berths', 'edit', getHandler));
|
||||||
49
src/app/api/v1/client-groups/[id]/handlers.ts
Normal file
49
src/app/api/v1/client-groups/[id]/handlers.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import {
|
||||||
|
archiveClientGroup,
|
||||||
|
getClientGroupById,
|
||||||
|
updateClientGroup,
|
||||||
|
} from '@/lib/services/client-groups.service';
|
||||||
|
import { updateClientGroupSchema } from '@/lib/validators/client-groups';
|
||||||
|
|
||||||
|
export const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const group = await getClientGroupById(params.id!, ctx.portId);
|
||||||
|
return NextResponse.json({ data: group });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, updateClientGroupSchema);
|
||||||
|
const updated = await updateClientGroup(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: updated });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
await archiveClientGroup(params.id!, ctx.portId, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
31
src/app/api/v1/client-groups/[id]/members/handlers.ts
Normal file
31
src/app/api/v1/client-groups/[id]/members/handlers.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { listGroupMembers, setGroupMembers } from '@/lib/services/client-groups.service';
|
||||||
|
import { setGroupMembersSchema } from '@/lib/validators/client-groups';
|
||||||
|
|
||||||
|
export const getMembersHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const members = await listGroupMembers(params.id!, ctx.portId);
|
||||||
|
return NextResponse.json({ data: members, total: members.length });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const putMembersHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const { clientIds } = await parseBody(req, setGroupMembersSchema);
|
||||||
|
await setGroupMembers(params.id!, ctx.portId, clientIds, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
6
src/app/api/v1/client-groups/[id]/members/route.ts
Normal file
6
src/app/api/v1/client-groups/[id]/members/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
|
import { getMembersHandler, putMembersHandler } from './handlers';
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('client_groups', 'view', getMembersHandler));
|
||||||
|
export const PUT = withAuth(withPermission('client_groups', 'manage', putMembersHandler));
|
||||||
7
src/app/api/v1/client-groups/[id]/route.ts
Normal file
7
src/app/api/v1/client-groups/[id]/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
|
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('client_groups', 'view', getHandler));
|
||||||
|
export const PATCH = withAuth(withPermission('client_groups', 'manage', patchHandler));
|
||||||
|
export const DELETE = withAuth(withPermission('client_groups', 'manage', deleteHandler));
|
||||||
31
src/app/api/v1/client-groups/handlers.ts
Normal file
31
src/app/api/v1/client-groups/handlers.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { createClientGroup, listClientGroups } from '@/lib/services/client-groups.service';
|
||||||
|
import { createClientGroupSchema } from '@/lib/validators/client-groups';
|
||||||
|
|
||||||
|
export const listHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const groups = await listClientGroups(ctx.portId);
|
||||||
|
return NextResponse.json({ data: groups, total: groups.length });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, createClientGroupSchema);
|
||||||
|
const group = await createClientGroup(ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: group }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
6
src/app/api/v1/client-groups/route.ts
Normal file
6
src/app/api/v1/client-groups/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
|
import { listHandler, createHandler } from './handlers';
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('client_groups', 'view', listHandler));
|
||||||
|
export const POST = withAuth(withPermission('client_groups', 'manage', createHandler));
|
||||||
@@ -16,8 +16,8 @@ import { createAuditLog } from '@/lib/audit';
|
|||||||
*/
|
*/
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission(
|
withPermission(
|
||||||
'admin',
|
'clients',
|
||||||
'manage_settings',
|
'gdpr_export',
|
||||||
withRateLimit('exports', async (req, ctx, params) => {
|
withRateLimit('exports', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const url = await getExportDownloadUrl(params.exportId!, ctx.portId);
|
const url = await getExportDownloadUrl(params.exportId!, ctx.portId);
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export const GET = withAuth(
|
|||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission(
|
withPermission(
|
||||||
'admin',
|
'clients',
|
||||||
'manage_settings',
|
'gdpr_export',
|
||||||
withRateLimit('exports', async (req, ctx, params) => {
|
withRateLimit('exports', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const body = await parseBody(req, requestSchema);
|
const body = await parseBody(req, requestSchema);
|
||||||
|
|||||||
8
src/app/api/v1/clients/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/clients/[id]/proxy/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
|
||||||
|
|
||||||
|
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('client');
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('clients', 'view', getHandler));
|
||||||
|
export const PUT = withAuth(withPermission('clients', 'edit', putHandler));
|
||||||
|
export const DELETE = withAuth(withPermission('clients', 'edit', deleteHandler));
|
||||||
@@ -48,6 +48,14 @@ export const POST = withAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = await getResolvedOcrConfig(ctx.portId);
|
const config = await getResolvedOcrConfig(ctx.portId);
|
||||||
|
// CM-6: manual-entry mode short-circuits ALL parsing - the operator
|
||||||
|
// types the details by hand. The client should skip this route entirely
|
||||||
|
// in manual mode, but we guard server-side too.
|
||||||
|
if (config.manualEntry) {
|
||||||
|
return NextResponse.json({
|
||||||
|
data: { parsed: EMPTY, source: 'manual', reason: 'manual-mode' },
|
||||||
|
});
|
||||||
|
}
|
||||||
// Tesseract.js (in-browser) is the default. The server only invokes
|
// Tesseract.js (in-browser) is the default. The server only invokes
|
||||||
// an AI provider when (a) the port admin has flipped `aiEnabled` on
|
// an AI provider when (a) the port admin has flipped `aiEnabled` on
|
||||||
// and (b) a key resolves. Otherwise the client falls back to its
|
// and (b) a key resolves. Otherwise the client falls back to its
|
||||||
|
|||||||
30
src/app/api/v1/inquiries/[id]/convert/route.ts
Normal file
30
src/app/api/v1/inquiries/[id]/convert/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
|
import { convertInquiryToClient, convertInquiryToInterest } from '@/lib/services/inquiries.service';
|
||||||
|
import { convertInquirySchema } from '@/lib/validators/inquiries';
|
||||||
|
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission('inquiries', 'manage', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const id = params.id;
|
||||||
|
if (!id) throw new ValidationError('id is required');
|
||||||
|
const { target } = await parseBody(req, convertInquirySchema);
|
||||||
|
const meta = {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
};
|
||||||
|
const data =
|
||||||
|
target === 'interest'
|
||||||
|
? await convertInquiryToInterest(id, ctx.portId, meta)
|
||||||
|
: await convertInquiryToClient(id, ctx.portId, meta);
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
18
src/app/api/v1/inquiries/[id]/route.ts
Normal file
18
src/app/api/v1/inquiries/[id]/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
|
import { getInquiryById } from '@/lib/services/inquiries.service';
|
||||||
|
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('inquiries', 'view', async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const id = params.id;
|
||||||
|
if (!id) throw new ValidationError('id is required');
|
||||||
|
const data = await getInquiryById(id, ctx.portId);
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
26
src/app/api/v1/inquiries/[id]/triage/route.ts
Normal file
26
src/app/api/v1/inquiries/[id]/triage/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
|
import { triageInquiry } from '@/lib/services/inquiries.service';
|
||||||
|
import { triageInquirySchema } from '@/lib/validators/inquiries';
|
||||||
|
|
||||||
|
export const PATCH = withAuth(
|
||||||
|
withPermission('inquiries', 'manage', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const id = params.id;
|
||||||
|
if (!id) throw new ValidationError('id is required');
|
||||||
|
const { state } = await parseBody(req, triageInquirySchema);
|
||||||
|
const data = await triageInquiry(id, ctx.portId, state, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
33
src/app/api/v1/inquiries/route.ts
Normal file
33
src/app/api/v1/inquiries/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseQuery } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { listInquiries } from '@/lib/services/inquiries.service';
|
||||||
|
import { listInquiriesSchema } from '@/lib/validators/inquiries';
|
||||||
|
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('inquiries', 'view', async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const query = parseQuery(req, listInquiriesSchema);
|
||||||
|
const result = await listInquiries(ctx.portId, query);
|
||||||
|
|
||||||
|
const { page, limit } = query;
|
||||||
|
const totalPages = Math.ceil(result.total / limit);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
data: result.data,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize: limit,
|
||||||
|
total: result.total,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Route handler for `/api/v1/interests/[id]/berths/[berthId]/price` (CM-2 Part B).
|
||||||
|
*
|
||||||
|
* Sets or clears the deal-specific price override for one (interest, berth).
|
||||||
|
* In handlers.ts so integration tests can call it directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { setBerthPriceOverride } from '@/lib/services/interest-berths.service';
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
price: z.number().nonnegative().nullable(),
|
||||||
|
currency: z.string().min(1).max(8).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const putHandler: RouteHandler<{ id: string; berthId: string }> = async (
|
||||||
|
req,
|
||||||
|
ctx,
|
||||||
|
params,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, bodySchema);
|
||||||
|
await setBerthPriceOverride(
|
||||||
|
params.id!,
|
||||||
|
params.berthId!,
|
||||||
|
body.price,
|
||||||
|
body.currency ?? null,
|
||||||
|
ctx.portId,
|
||||||
|
);
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
|
||||||
|
import { putHandler } from './handlers';
|
||||||
|
|
||||||
|
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));
|
||||||
8
src/app/api/v1/interests/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/interests/[id]/proxy/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
|
||||||
|
|
||||||
|
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('interest');
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('interests', 'view', getHandler));
|
||||||
|
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));
|
||||||
|
export const DELETE = withAuth(withPermission('interests', 'edit', deleteHandler));
|
||||||
8
src/app/api/v1/yachts/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/yachts/[id]/proxy/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
|
||||||
|
|
||||||
|
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('yacht');
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
|
||||||
|
export const PUT = withAuth(withPermission('yachts', 'edit', putHandler));
|
||||||
|
export const DELETE = withAuth(withPermission('yachts', 'edit', deleteHandler));
|
||||||
@@ -30,6 +30,7 @@ interface ConfigResp {
|
|||||||
hasApiKey: boolean;
|
hasApiKey: boolean;
|
||||||
useGlobal: boolean;
|
useGlobal: boolean;
|
||||||
aiEnabled: boolean;
|
aiEnabled: boolean;
|
||||||
|
manualEntry: boolean;
|
||||||
};
|
};
|
||||||
models: Record<Provider, string[]>;
|
models: Record<Provider, string[]>;
|
||||||
}
|
}
|
||||||
@@ -54,7 +55,7 @@ function SettingsBlock(props: SettingsBlockProps) {
|
|||||||
// Key the body on the loaded payload so useState initializers seed
|
// Key the body on the loaded payload so useState initializers seed
|
||||||
// from server values cleanly.
|
// from server values cleanly.
|
||||||
const sig = data?.data
|
const sig = data?.data
|
||||||
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}`
|
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}:${data.data.manualEntry}`
|
||||||
: 'loading';
|
: 'loading';
|
||||||
return (
|
return (
|
||||||
<SettingsBlockBody
|
<SettingsBlockBody
|
||||||
@@ -89,6 +90,7 @@ function SettingsBlockBody({
|
|||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
|
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
|
||||||
const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false);
|
const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false);
|
||||||
|
const [manualEntry, setManualEntry] = useState(data?.data.manualEntry ?? 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,
|
||||||
);
|
);
|
||||||
@@ -105,6 +107,7 @@ function SettingsBlockBody({
|
|||||||
clearApiKey: Boolean(clearApiKey),
|
clearApiKey: Boolean(clearApiKey),
|
||||||
useGlobal: scope === 'global' ? false : useGlobal,
|
useGlobal: scope === 'global' ? false : useGlobal,
|
||||||
aiEnabled: scope === 'global' ? false : aiEnabled,
|
aiEnabled: scope === 'global' ? false : aiEnabled,
|
||||||
|
manualEntry: scope === 'global' ? false : manualEntry,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -190,6 +193,25 @@ function SettingsBlockBody({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{scope === 'port' ? (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<Checkbox
|
||||||
|
id={`manualEntry-${scope}`}
|
||||||
|
checked={manualEntry}
|
||||||
|
onCheckedChange={(v) => setManualEntry(v === true)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor={`manualEntry-${scope}`} className="text-sm font-medium">
|
||||||
|
Manual entry only (skip receipt scanning)
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When on, staff just attach a receipt photo and type the details by hand - no
|
||||||
|
on-device or AI parsing runs. Takes precedence over AI parsing above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor={`provider-${scope}`}>Provider</Label>
|
<Label htmlFor={`provider-${scope}`}>Provider</Label>
|
||||||
|
|||||||
@@ -50,20 +50,25 @@ export function OnboardingBanner() {
|
|||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Sparkles className="size-4 shrink-0" aria-hidden />
|
<Sparkles className="size-4 shrink-0" aria-hidden />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
<strong>Setup is {data.percent}% complete</strong>. {data.completed} of {data.total} steps
|
<strong>Setup is {data.percent}% complete</strong>
|
||||||
done.{' '}
|
{/* Verbose progress + the "Next:" deep-link are hidden on mobile,
|
||||||
{next ? (
|
where they get clipped (R1) and duplicate the always-visible
|
||||||
<>
|
"View checklist" button. Shown from sm: up. */}
|
||||||
Next:{' '}
|
<span className="hidden sm:inline">
|
||||||
<Link
|
. {data.completed} of {data.total} steps done.{' '}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
{next ? (
|
||||||
href={`/${portSlug}/admin/${next.href}` as any}
|
<>
|
||||||
className="font-medium underline-offset-2 hover:underline"
|
Next:{' '}
|
||||||
>
|
<Link
|
||||||
{next.label}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
</Link>
|
href={`/${portSlug}/admin/${next.href}` as any}
|
||||||
</>
|
className="font-medium underline-offset-2 hover:underline"
|
||||||
) : null}
|
>
|
||||||
|
{next.label}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
|||||||
delete: false,
|
delete: false,
|
||||||
change_stage: false,
|
change_stage: false,
|
||||||
},
|
},
|
||||||
|
inquiries: {
|
||||||
|
view: false,
|
||||||
|
manage: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
@@ -126,6 +130,7 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
admin: 'Administration',
|
admin: 'Administration',
|
||||||
residential_clients: 'Residential Clients',
|
residential_clients: 'Residential Clients',
|
||||||
residential_interests: 'Residential Interests',
|
residential_interests: 'Residential Interests',
|
||||||
|
inquiries: 'Inquiries',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatAction(action: string): string {
|
function formatAction(action: string): string {
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'assignment_enabled',
|
||||||
|
label: 'Interest Assignment',
|
||||||
|
description:
|
||||||
|
'Allow assigning interests to sales users (the "Assigned to" owner chip + auto-assign on create). Off by default - turn on only when more than one person works the pipeline. Disabling hides the assignment UI and stops auto-assigning new interests; existing assignment data is preserved and reappears if you re-enable.',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'tenancies_module_enabled',
|
key: 'tenancies_module_enabled',
|
||||||
label: 'Tenancies Module',
|
label: 'Tenancies Module',
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { formatRole } from '@/lib/constants';
|
import { formatRole, NON_ASSIGNABLE_ROLE_NAMES } from '@/lib/constants';
|
||||||
|
|
||||||
interface Role {
|
interface Role {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -78,12 +78,20 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
const roles = rolesQuery.data?.data ?? [];
|
const roles = rolesQuery.data?.data ?? [];
|
||||||
|
// Hide retired/owner-only system roles from the picker, but always keep the
|
||||||
|
// role the user being edited already holds so their record stays editable.
|
||||||
|
const selectableRoles = roles.filter(
|
||||||
|
(r) => !NON_ASSIGNABLE_ROLE_NAMES.has(r.name) || r.id === user?.role.id,
|
||||||
|
);
|
||||||
const [firstName, setFirstName] = useState(initialNames.first);
|
const [firstName, setFirstName] = useState(initialNames.first);
|
||||||
const [lastName, setLastName] = useState(initialNames.last);
|
const [lastName, setLastName] = useState(initialNames.last);
|
||||||
const [email, setEmail] = useState(user?.email ?? '');
|
const [email, setEmail] = useState(user?.email ?? '');
|
||||||
const [originalEmail] = 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('');
|
||||||
|
// New users: email them a set-password link by default rather than typing a
|
||||||
|
// password here. Toggle off to set one manually.
|
||||||
|
const [sendSetupEmail, setSendSetupEmail] = useState(true);
|
||||||
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
||||||
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
|
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
|
||||||
user?.phone ? { e164: user.phone, country: 'US' } : null,
|
user?.phone ? { e164: user.phone, country: 'US' } : null,
|
||||||
@@ -141,7 +149,9 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|||||||
firstName: firstName || null,
|
firstName: firstName || null,
|
||||||
lastName: lastName || null,
|
lastName: lastName || null,
|
||||||
email,
|
email,
|
||||||
password,
|
// Email mode omits the password entirely; manual mode sends it.
|
||||||
|
password: sendSetupEmail ? undefined : password,
|
||||||
|
sendSetupEmail,
|
||||||
displayName,
|
displayName,
|
||||||
phone: phoneE164 ?? undefined,
|
phone: phoneE164 ?? undefined,
|
||||||
roleId,
|
roleId,
|
||||||
@@ -250,18 +260,37 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isEdit && (
|
{!isEdit && (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
<Label htmlFor="user-password">Password</Label>
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
<Input
|
<div>
|
||||||
id="user-password"
|
<Label htmlFor="user-setup-email">Email a set-password link</Label>
|
||||||
type="password"
|
<p className="text-xs text-muted-foreground">
|
||||||
value={password}
|
The user gets an email to choose their own password. Turn off to set one
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
here instead.
|
||||||
placeholder="Min 12 characters"
|
</p>
|
||||||
minLength={12}
|
</div>
|
||||||
required
|
<Switch
|
||||||
/>
|
id="user-setup-email"
|
||||||
</div>
|
checked={sendSetupEmail}
|
||||||
|
onCheckedChange={setSendSetupEmail}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!sendSetupEmail && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="user-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="user-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Min 12 characters"
|
||||||
|
minLength={12}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -281,7 +310,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|||||||
<SelectValue placeholder="Select a role" />
|
<SelectValue placeholder="Select a role" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{roles.map((r) => (
|
{selectableRoles.map((r) => (
|
||||||
<SelectItem key={r.id} value={r.id}>
|
<SelectItem key={r.id} value={r.id}>
|
||||||
{formatRole(r.name)}
|
{formatRole(r.name)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
<div className="flex shrink-0 items-start gap-1 opacity-0 transition-opacity duration-base ease-spring group-hover:opacity-100 focus-within:opacity-100">
|
<div className="flex shrink-0 items-start gap-1 text-muted-foreground">
|
||||||
{!acknowledged ? (
|
{!acknowledged ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { useState } from 'react';
|
|||||||
import { ShieldAlert } from 'lucide-react';
|
import { ShieldAlert } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { AlertCard, AlertCardEmpty } from './alert-card';
|
import { AlertCard, AlertCardEmpty } from './alert-card';
|
||||||
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
|
import { useAlertCount, useAlertList, useAlertRealtime, useDismissAll } from './use-alerts';
|
||||||
import type { AlertStatus } from './types';
|
import type { AlertStatus } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +31,7 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
|
|||||||
|
|
||||||
const total = count?.total ?? 0;
|
const total = count?.total ?? 0;
|
||||||
const alerts = data?.data ?? [];
|
const alerts = data?.data ?? [];
|
||||||
|
const dismissAll = useDismissAll();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={embedded ? 'space-y-3' : 'space-y-6'}>
|
<div className={embedded ? 'space-y-3' : 'space-y-6'}>
|
||||||
@@ -62,6 +64,18 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value={tab} className="mt-4 space-y-2">
|
<TabsContent value={tab} className="mt-4 space-y-2">
|
||||||
|
{tab === 'open' && alerts.length > 0 ? (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => dismissAll.mutate({})}
|
||||||
|
disabled={dismissAll.isPending}
|
||||||
|
>
|
||||||
|
Dismiss all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-20 w-full" />
|
<Skeleton className="h-20 w-full" />
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ export function useAlertActions() {
|
|||||||
return { acknowledge, dismiss };
|
return { acknowledge, dismiss };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDismissAll() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (filter: { ruleId?: string; severity?: string } = {}) =>
|
||||||
|
apiFetch('/api/v1/alerts/dismiss-all', { method: 'POST', body: filter }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alerts'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useAlertRealtime() {
|
export function useAlertRealtime() {
|
||||||
useRealtimeInvalidation({
|
useRealtimeInvalidation({
|
||||||
'alert:created': [['alerts']],
|
'alert:created': [['alerts']],
|
||||||
|
|||||||
172
src/components/berths/berth-price-reconcile-table.tsx
Normal file
172
src/components/berths/berth-price-reconcile-table.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk berth price-reconcile table (CM-2 Part A).
|
||||||
|
*
|
||||||
|
* Lists the price parsed from each berth's current spec sheet next to the stored
|
||||||
|
* price, with per-row + select-all approval. Nothing is written until the rep
|
||||||
|
* approves — the apply mutation posts only the checked, changed rows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { EmptyState } from '@/components/ui/empty-state';
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
berthId: string;
|
||||||
|
mooringNumber: string;
|
||||||
|
area: string | null;
|
||||||
|
currentPrice: number | null;
|
||||||
|
currentCurrency: string;
|
||||||
|
parsedPrice: number | null;
|
||||||
|
parsedCurrency: string | null;
|
||||||
|
status: 'changed' | 'matched' | 'needs_review' | 'no_pdf';
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLE: Record<Row['status'], string> = {
|
||||||
|
changed: 'bg-amber-100 text-amber-800',
|
||||||
|
matched: 'bg-muted text-muted-foreground',
|
||||||
|
needs_review: 'bg-red-100 text-red-700',
|
||||||
|
no_pdf: 'bg-slate-100 text-slate-500',
|
||||||
|
};
|
||||||
|
const STATUS_LABEL: Record<Row['status'], string> = {
|
||||||
|
changed: 'Changed',
|
||||||
|
matched: 'Matched',
|
||||||
|
needs_review: 'Needs review',
|
||||||
|
no_pdf: 'No PDF',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmt = (n: number | null, ccy: string | null) =>
|
||||||
|
n == null ? '—' : `${n.toLocaleString()} ${ccy ?? ''}`.trim();
|
||||||
|
|
||||||
|
export function BerthPriceReconcileTable() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data, isLoading } = useQuery<{ data: Row[] }>({
|
||||||
|
queryKey: ['berths', 'price-reconcile'],
|
||||||
|
queryFn: () => apiFetch('/api/v1/berths/price-reconcile'),
|
||||||
|
});
|
||||||
|
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||||
|
const selectable = useMemo(() => rows.filter((r) => r.status === 'changed'), [rows]);
|
||||||
|
const [checked, setChecked] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const apply = useMutation({
|
||||||
|
mutationFn: async (): Promise<{ data: { updated: number } }> => {
|
||||||
|
const approvals = selectable
|
||||||
|
.filter((r) => checked[r.berthId] && r.parsedPrice != null)
|
||||||
|
.map((r) => ({
|
||||||
|
berthId: r.berthId,
|
||||||
|
price: r.parsedPrice as number,
|
||||||
|
currency: r.parsedCurrency ?? r.currentCurrency,
|
||||||
|
}));
|
||||||
|
return apiFetch('/api/v1/berths/price-reconcile/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { approvals },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast.success(`Updated ${res.data.updated} berth price(s).`);
|
||||||
|
setChecked({});
|
||||||
|
void qc.invalidateQueries({ queryKey: ['berths'] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toastError(e),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="p-6 text-sm text-muted-foreground">Parsing spec sheets…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState title="No berths to reconcile" body="No active berths found for this port." />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allChecked = selectable.length > 0 && selectable.every((r) => checked[r.berthId]);
|
||||||
|
const selectedCount = selectable.filter((r) => checked[r.berthId]).length;
|
||||||
|
const reviewCount = rows.filter((r) => r.status === 'needs_review').length;
|
||||||
|
const noPdfCount = rows.filter((r) => r.status === 'no_pdf').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{selectable.length} changed · {reviewCount} need review · {noPdfCount} without a PDF
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={selectedCount === 0 || apply.isPending}
|
||||||
|
onClick={() => apply.mutate()}
|
||||||
|
>
|
||||||
|
{apply.isPending ? 'Applying…' : `Approve selected (${selectedCount})`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-md border bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/30 text-start text-xs text-muted-foreground">
|
||||||
|
<th className="w-10 p-2 ps-3">
|
||||||
|
<Checkbox
|
||||||
|
aria-label="Select all changed"
|
||||||
|
checked={allChecked}
|
||||||
|
onCheckedChange={(c) =>
|
||||||
|
setChecked(
|
||||||
|
c === true
|
||||||
|
? Object.fromEntries(selectable.map((r) => [r.berthId, true]))
|
||||||
|
: {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="p-2">Mooring</th>
|
||||||
|
<th className="p-2">Area</th>
|
||||||
|
<th className="p-2 text-end">Current</th>
|
||||||
|
<th className="p-2 text-end">Parsed</th>
|
||||||
|
<th className="p-2">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.berthId} className="border-b last:border-0">
|
||||||
|
<td className="p-2 ps-3">
|
||||||
|
{r.status === 'changed' ? (
|
||||||
|
<Checkbox
|
||||||
|
aria-label={`Approve ${r.mooringNumber}`}
|
||||||
|
checked={!!checked[r.berthId]}
|
||||||
|
onCheckedChange={(c) =>
|
||||||
|
setChecked((p) => ({ ...p, [r.berthId]: c === true }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 font-medium">{r.mooringNumber}</td>
|
||||||
|
<td className="p-2 text-muted-foreground">{r.area ?? '—'}</td>
|
||||||
|
<td className="p-2 text-end tabular-nums">
|
||||||
|
{fmt(r.currentPrice, r.currentCurrency)}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-end tabular-nums">
|
||||||
|
{fmt(r.parsedPrice, r.parsedCurrency)}
|
||||||
|
</td>
|
||||||
|
<td className="p-2">
|
||||||
|
<span className={`rounded px-2 py-0.5 text-xs ${STATUS_STYLE[r.status]}`}>
|
||||||
|
{STATUS_LABEL[r.status]}
|
||||||
|
</span>
|
||||||
|
{r.warning ? (
|
||||||
|
<span className="ms-2 text-xs text-muted-foreground">{r.warning}</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
304
src/components/client-groups/client-group-detail.tsx
Normal file
304
src/components/client-groups/client-group-detail.tsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Copy, CopyCheck, Trash2, UserCog, Users } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
|
interface GroupMember {
|
||||||
|
clientId: string;
|
||||||
|
fullName: string;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
interface ClientOption {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
primaryEmail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string, successMsg: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success(successMsg);
|
||||||
|
} catch {
|
||||||
|
toast.error('Copy failed — clipboard unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientGroupDetail({ groupId }: { groupId: string }) {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
const router = useRouter();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [manageOpen, setManageOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: groupResp } = useQuery<{ data: { id: string; name: string; color: string } }>({
|
||||||
|
queryKey: ['client-group', groupId],
|
||||||
|
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}`),
|
||||||
|
});
|
||||||
|
const { data: membersResp, isLoading } = useQuery<{ data: GroupMember[] }>({
|
||||||
|
queryKey: ['client-group', groupId, 'members'],
|
||||||
|
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}/members`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const group = groupResp?.data;
|
||||||
|
const members = useMemo(() => membersResp?.data ?? [], [membersResp]);
|
||||||
|
const emails = members.map((m) => m.email).filter((e): e is string => !!e);
|
||||||
|
|
||||||
|
const archive = useMutation({
|
||||||
|
mutationFn: () => apiFetch(`/api/v1/client-groups/${groupId}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Group archived');
|
||||||
|
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||||
|
router.push(`/${portSlug}/client-groups` as Route);
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/client-groups` as Route}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
All groups
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title={group?.name ?? 'Group'}
|
||||||
|
eyebrow="Mailing group"
|
||||||
|
kpiLine={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Users className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{members.length} {members.length === 1 ? 'member' : 'members'}
|
||||||
|
{emails.length < members.length ? (
|
||||||
|
<span className="text-amber-700">
|
||||||
|
· {members.length - emails.length} without email
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
variant="gradient"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={emails.length === 0}
|
||||||
|
onClick={() =>
|
||||||
|
copyToClipboard(emails.join(', '), `Copied ${emails.length} email addresses`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CopyCheck className="me-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Copy all emails
|
||||||
|
</Button>
|
||||||
|
<PermissionGate resource="client_groups" action="manage">
|
||||||
|
<Button variant="outline" onClick={() => setManageOpen(true)}>
|
||||||
|
<UserCog className="me-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Manage members
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
<PermissionGate resource="client_groups" action="manage">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Archive this group? Members are kept; the group is hidden.')) {
|
||||||
|
archive.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="me-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading members…</p>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="No members yet"
|
||||||
|
description="Use “Manage members” to add clients to this group."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-medium">Client</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Email</th>
|
||||||
|
<th className="px-4 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{members.map((m) => (
|
||||||
|
<tr key={m.clientId} className="hover:bg-muted/30">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/clients/${m.clientId}` as Route}
|
||||||
|
className="text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{m.fullName}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground">{m.email ?? '—'}</td>
|
||||||
|
<td className="px-4 py-2 text-end">
|
||||||
|
{m.email ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyToClipboard(m.email!, 'Email copied')}
|
||||||
|
aria-label={`Copy ${m.email}`}
|
||||||
|
title="Copy email"
|
||||||
|
className="rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{manageOpen ? (
|
||||||
|
<ManageMembersDialog
|
||||||
|
groupId={groupId}
|
||||||
|
open={manageOpen}
|
||||||
|
onOpenChange={setManageOpen}
|
||||||
|
currentIds={members.map((m) => m.clientId)}
|
||||||
|
onSaved={() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['client-group', groupId, 'members'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManageMembersDialog({
|
||||||
|
groupId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
currentIds,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
groupId: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
currentIds: string[];
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds));
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<{ data: ClientOption[] }>({
|
||||||
|
queryKey: ['clients', 'group-picker'],
|
||||||
|
queryFn: () => apiFetch('/api/v1/clients?limit=1000'),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clients = data?.data ?? [];
|
||||||
|
const filtered = clients.filter((c) =>
|
||||||
|
`${c.fullName} ${c.primaryEmail ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiFetch(`/api/v1/client-groups/${groupId}/members`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { clientIds: Array.from(selected) },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Members updated');
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Manage members</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Tick the clients who belong in this group. {selected.size} selected.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
placeholder="Search clients…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="max-h-80 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="p-2 text-sm text-muted-foreground">Loading clients…</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="p-2 text-sm text-muted-foreground">No matching clients.</p>
|
||||||
|
) : (
|
||||||
|
filtered.map((c) => (
|
||||||
|
<label
|
||||||
|
key={c.id}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Checkbox checked={selected.has(c.id)} onCheckedChange={() => toggle(c.id)} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm text-foreground">{c.fullName}</span>
|
||||||
|
{c.primaryEmail ? (
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{c.primaryEmail}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => save.mutate()} disabled={save.isPending}>
|
||||||
|
Save members
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
src/components/client-groups/client-groups-list.tsx
Normal file
170
src/components/client-groups/client-groups-list.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Users } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
|
interface ClientGroupRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
color: string;
|
||||||
|
memberCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientGroupsList() {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [color, setColor] = useState('#6B7280');
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<{ data: ClientGroupRow[] }>({
|
||||||
|
queryKey: ['client-groups'],
|
||||||
|
queryFn: () => apiFetch('/api/v1/client-groups'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiFetch('/api/v1/client-groups', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: name.trim(), description: description.trim() || null, color },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Group created');
|
||||||
|
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||||
|
setOpen(false);
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
setColor('#6B7280');
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = data?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Client Groups"
|
||||||
|
eyebrow="Mailing"
|
||||||
|
description="Group clients into mailing lists. View members, copy their emails, and (once wired) sync to Mailchimp."
|
||||||
|
variant="gradient"
|
||||||
|
actions={
|
||||||
|
<PermissionGate resource="client_groups" action="manage">
|
||||||
|
<Button onClick={() => setOpen(true)}>
|
||||||
|
<Plus className="me-1.5 h-4 w-4" aria-hidden />
|
||||||
|
New group
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : groups.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="No groups yet"
|
||||||
|
description="Create a group to start organising clients into mailing lists."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<Link
|
||||||
|
key={g.id}
|
||||||
|
href={`/${portSlug}/client-groups/${g.id}` as Route}
|
||||||
|
className="group rounded-xl border border-border bg-card p-4 transition-colors hover:border-brand/40 hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: g.color }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<h3 className="truncate font-medium text-foreground">{g.name}</h3>
|
||||||
|
</div>
|
||||||
|
{g.description ? (
|
||||||
|
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{g.description}</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Users className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{g.memberCount} {g.memberCount === 1 ? 'member' : 'members'}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New client group</DialogTitle>
|
||||||
|
<DialogDescription>A named mailing/segment group for this port.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="cg-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="cg-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Newsletter subscribers"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="cg-desc">Description (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="cg-desc"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="cg-color">Color</Label>
|
||||||
|
<input
|
||||||
|
id="cg-color"
|
||||||
|
type="color"
|
||||||
|
value={color}
|
||||||
|
onChange={(e) => setColor(e.target.value)}
|
||||||
|
className="h-9 w-16 cursor-pointer rounded-md border border-border bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => create.mutate()} disabled={!name.trim() || create.isPending}>
|
||||||
|
Create group
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,11 +3,9 @@
|
|||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import type { Route } from 'next';
|
import type { Route } from 'next';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react';
|
import { Archive, Bell, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
@@ -56,18 +54,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
const primaryEmail =
|
const primaryEmail =
|
||||||
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
|
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
|
||||||
client.contacts?.find((c) => c.channel === 'email')?.value;
|
client.contacts?.find((c) => c.channel === 'email')?.value;
|
||||||
const primaryPhoneContact =
|
|
||||||
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
|
|
||||||
client.contacts?.find((c) => c.channel === 'phone');
|
|
||||||
const primaryPhone = primaryPhoneContact?.value;
|
|
||||||
// wa.me requires the E.164 number without the leading "+". Strip from the
|
|
||||||
// canonical E.164 form when available; otherwise strip non-digits from the
|
|
||||||
// display value as a best-effort fallback.
|
|
||||||
const whatsappNumber = primaryPhoneContact?.valueE164
|
|
||||||
? primaryPhoneContact.valueE164.replace(/^\+/, '')
|
|
||||||
: primaryPhoneContact?.value
|
|
||||||
? primaryPhoneContact.value.replace(/[^\d]/g, '')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
||||||
const addedLabel = client.createdAt
|
const addedLabel = client.createdAt
|
||||||
@@ -107,52 +93,11 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
{/* CM-4: Email/Call/WhatsApp deep-link pills removed at client
|
||||||
{primaryEmail ? (
|
request. GDPR export moved to the top-right action cluster.
|
||||||
<Button
|
Portal-invite stays as the one primary CTA here. */}
|
||||||
asChild
|
{!isArchived && client.clientPortalEnabled === true ? (
|
||||||
variant="outline"
|
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
|
|
||||||
<Mail />
|
|
||||||
Email
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{primaryPhone ? (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
|
|
||||||
<Phone />
|
|
||||||
Call
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{whatsappNumber ? (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`https://wa.me/${whatsappNumber}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={`Message ${primaryPhone} on WhatsApp`}
|
|
||||||
>
|
|
||||||
<WhatsAppIcon className="h-4 w-4" />
|
|
||||||
WhatsApp
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{!isArchived && client.clientPortalEnabled === true ? (
|
|
||||||
<div className="hidden sm:inline-flex">
|
<div className="hidden sm:inline-flex">
|
||||||
<PortalInviteButton
|
<PortalInviteButton
|
||||||
clientId={client.id}
|
clientId={client.id}
|
||||||
@@ -160,11 +105,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
defaultEmail={primaryEmail}
|
defaultEmail={primaryEmail}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
<div className="hidden sm:inline-flex">
|
|
||||||
<GdprExportButton clientId={client.id} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
{client.tags && client.tags.length > 0 && (
|
{client.tags && client.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
@@ -179,6 +121,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
right perm) permanently-delete. Destructive actions sit out
|
right perm) permanently-delete. Destructive actions sit out
|
||||||
of the primary action flow. */}
|
of the primary action flow. */}
|
||||||
<div className="flex items-start gap-1">
|
<div className="flex items-start gap-1">
|
||||||
|
{/* CM-4: GDPR export relocated here as a compact icon trigger,
|
||||||
|
alongside reminder/archive/delete. Self-gates on permission. */}
|
||||||
|
<GdprExportButton clientId={client.id} variant="icon" />
|
||||||
{isArchived && (
|
{isArchived && (
|
||||||
<PermissionGate resource="admin" action="permanently_delete_clients">
|
<PermissionGate resource="admin" action="permanently_delete_clients">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { RemindersInline } from '@/components/reminders/reminders-inline';
|
|||||||
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
||||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||||
import { NotesList } from '@/components/shared/notes-list';
|
import { NotesList } from '@/components/shared/notes-list';
|
||||||
|
import { ProxyCard } from '@/components/shared/proxy-card';
|
||||||
import type { CountryCode } from '@/lib/i18n/countries';
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
|
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
|
||||||
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
|
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
|
||||||
@@ -156,6 +157,9 @@ function OverviewTab({
|
|||||||
<ClientPipelineSummary clientId={clientId} variant="panel" />
|
<ClientPipelineSummary clientId={clientId} variant="panel" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* CM-9: point-of-contact (default level for the client). */}
|
||||||
|
<ProxyCard entityType="client" entityId={clientId} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Personal Info */}
|
{/* Personal Info */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -48,14 +48,22 @@ const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'des
|
|||||||
failed: 'destructive',
|
failed: 'destructive',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GdprExportButton({ clientId }: { clientId: string }) {
|
export function GdprExportButton({
|
||||||
|
clientId,
|
||||||
|
variant = 'button',
|
||||||
|
}: {
|
||||||
|
clientId: string;
|
||||||
|
/** `button` = standalone outline button (default). `icon` = compact icon-only
|
||||||
|
* trigger for the detail-header top-right action cluster (CM-4). */
|
||||||
|
variant?: 'button' | 'icon';
|
||||||
|
}) {
|
||||||
const { can, isSuperAdmin } = usePermissions();
|
const { can, isSuperAdmin } = usePermissions();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [emailToClient, setEmailToClient] = useState(false);
|
const [emailToClient, setEmailToClient] = useState(false);
|
||||||
const [emailOverride, setEmailOverride] = useState('');
|
const [emailOverride, setEmailOverride] = useState('');
|
||||||
|
|
||||||
const allowed = isSuperAdmin || can('admin', 'manage_settings');
|
const allowed = isSuperAdmin || can('clients', 'gdpr_export');
|
||||||
|
|
||||||
const queryKey = ['gdpr-exports', clientId];
|
const queryKey = ['gdpr-exports', clientId];
|
||||||
const { data, isLoading } = useQuery<ListResp>({
|
const { data, isLoading } = useQuery<ListResp>({
|
||||||
@@ -110,10 +118,21 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="h-8">
|
{variant === 'icon' ? (
|
||||||
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
<button
|
||||||
GDPR export
|
type="button"
|
||||||
</Button>
|
aria-label="GDPR export"
|
||||||
|
title="GDPR export"
|
||||||
|
className="shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FileDown className="size-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" className="h-8">
|
||||||
|
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||||
|
GDPR export
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function FilePreviewDialog({
|
|||||||
|
|
||||||
// useQuery replaces the prior useEffect(fetch+setState) pattern. The
|
// useQuery replaces the prior useEffect(fetch+setState) pattern. The
|
||||||
// request is gated on the dialog being open and a fileId being set.
|
// request is gated on the dialog being open and a fileId being set.
|
||||||
const previewQuery = useQuery<{ data: { url: string } }>({
|
const previewQuery = useQuery<{ data: { url: string; mimeType?: string } }>({
|
||||||
queryKey: ['file-preview', fileId],
|
queryKey: ['file-preview', fileId],
|
||||||
queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`),
|
queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`),
|
||||||
enabled: open && !!fileId,
|
enabled: open && !!fileId,
|
||||||
@@ -113,7 +113,13 @@ export function FilePreviewDialog({
|
|||||||
const loading = previewQuery.isLoading;
|
const loading = previewQuery.isLoading;
|
||||||
const error = previewQuery.error ? 'Failed to load preview' : null;
|
const error = previewQuery.error ? 'Failed to load preview' : null;
|
||||||
|
|
||||||
const kind = previewKindFor(mimeType, fileName);
|
// Prefer the caller-supplied mime, but fall back to the server's resolved
|
||||||
|
// mime (getPreviewUrl returns it). Without this, callers that pass only a
|
||||||
|
// display name (e.g. the EOI tab passing "EOI - <client>") or files whose
|
||||||
|
// stored name lacks a `.pdf` extension (migration-backfilled EOIs) fall
|
||||||
|
// through to the "unknown" surface even though the server knows it's a PDF.
|
||||||
|
const resolvedMime = mimeType ?? previewQuery.data?.data.mimeType;
|
||||||
|
const kind = previewKindFor(resolvedMime, fileName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
|||||||
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
|
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
|
||||||
import { ReminderList } from '@/components/reminders/reminder-list';
|
import { ReminderList } from '@/components/reminders/reminder-list';
|
||||||
import { useAlertCount } from '@/components/alerts/use-alerts';
|
import { useAlertCount } from '@/components/alerts/use-alerts';
|
||||||
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merged "Inbox" surface - replaces the previously-separate /alerts and
|
* Merged "Inbox" surface - replaces the previously-separate /alerts and
|
||||||
@@ -29,6 +30,11 @@ export function InboxPageShell() {
|
|||||||
const [alertsOpen, setAlertsOpen] = useState(true);
|
const [alertsOpen, setAlertsOpen] = useState(true);
|
||||||
const [remindersOpen, setRemindersOpen] = useState(true);
|
const [remindersOpen, setRemindersOpen] = useState(true);
|
||||||
const { data: alertCount } = useAlertCount();
|
const { data: alertCount } = useAlertCount();
|
||||||
|
// The deal-alert feed (stale interests, overdue signers, …) is gated on
|
||||||
|
// interests.view — operational roles see it; external residential partners
|
||||||
|
// don't. Hide the whole section rather than letting its query 403.
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const canSeeAlerts = can('interests', 'view');
|
||||||
|
|
||||||
// localStorage hydration on mount - canonical "read from external
|
// localStorage hydration on mount - canonical "read from external
|
||||||
// store" pattern. setState in effect is intentional.
|
// store" pattern. setState in effect is intentional.
|
||||||
@@ -95,20 +101,22 @@ export function InboxPageShell() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
|
{canSeeAlerts ? (
|
||||||
<SectionHeader
|
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
|
||||||
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
|
<SectionHeader
|
||||||
label="Alerts"
|
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
|
||||||
count={activeAlerts}
|
label="Alerts"
|
||||||
open={alertsOpen}
|
count={activeAlerts}
|
||||||
onToggle={toggleAlerts}
|
open={alertsOpen}
|
||||||
/>
|
onToggle={toggleAlerts}
|
||||||
{alertsOpen ? (
|
/>
|
||||||
<div className="border-t px-4 pb-4 pt-3">
|
{alertsOpen ? (
|
||||||
<AlertsPageShell embedded />
|
<div className="border-t px-4 pb-4 pt-3">
|
||||||
</div>
|
<AlertsPageShell embedded />
|
||||||
) : null}
|
</div>
|
||||||
</section>
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/components/inquiries/inquiry-card.tsx
Normal file
35
src/components/inquiries/inquiry-card.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { KIND_LABELS, TRIAGE_TONE, type InquiryRow } from '@/components/inquiries/inquiry-columns';
|
||||||
|
|
||||||
|
export function InquiryCard({ inquiry, portSlug }: { inquiry: InquiryRow; portSlug: string }) {
|
||||||
|
return (
|
||||||
|
<Link href={`/${portSlug}/inquiries/${inquiry.id}`} className="block">
|
||||||
|
<Card className="transition-shadow hover:shadow-sm">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{inquiry.contactName || '(no name)'}</p>
|
||||||
|
{inquiry.contactEmail ? (
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{inquiry.contactEmail}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Badge className={TRIAGE_TONE[inquiry.triageState]}>{inquiry.triageState}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{KIND_LABELS[inquiry.kind]}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>
|
||||||
|
{formatDistanceToNowStrict(new Date(inquiry.receivedAt), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
src/components/inquiries/inquiry-columns.tsx
Normal file
224
src/components/inquiries/inquiry-columns.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
import { MoreHorizontal, UserCheck, X, ExternalLink } from 'lucide-react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
|
export type InquiryKind = 'berth_inquiry' | 'residence_inquiry' | 'contact_form';
|
||||||
|
export type InquiryTriageState = 'open' | 'assigned' | 'converted' | 'dismissed';
|
||||||
|
|
||||||
|
export interface InquiryRow {
|
||||||
|
id: string;
|
||||||
|
kind: InquiryKind;
|
||||||
|
contactName: string | null;
|
||||||
|
contactEmail: string | null;
|
||||||
|
receivedAt: string;
|
||||||
|
triageState: InquiryTriageState;
|
||||||
|
convertedClientId: string | null;
|
||||||
|
convertedInterestId: string | null;
|
||||||
|
sourceIp: string | null;
|
||||||
|
utmSource?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KIND_LABELS: Record<InquiryKind, string> = {
|
||||||
|
berth_inquiry: 'Berth',
|
||||||
|
residence_inquiry: 'Residence',
|
||||||
|
contact_form: 'Contact',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_TONE: Record<InquiryKind, string> = {
|
||||||
|
berth_inquiry: 'bg-blue-100 text-blue-800',
|
||||||
|
residence_inquiry: 'bg-amber-100 text-amber-900',
|
||||||
|
contact_form: 'bg-slate-100 text-slate-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRIAGE_TONE: Record<InquiryTriageState, string> = {
|
||||||
|
open: 'bg-blue-100 text-blue-800',
|
||||||
|
assigned: 'bg-amber-100 text-amber-900',
|
||||||
|
converted: 'bg-emerald-100 text-emerald-800',
|
||||||
|
dismissed: 'bg-slate-100 text-slate-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRIAGE_LABELS: Record<InquiryTriageState, string> = {
|
||||||
|
open: 'Open',
|
||||||
|
assigned: 'Assigned',
|
||||||
|
converted: 'Converted',
|
||||||
|
dismissed: 'Dismissed',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INQUIRY_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
|
||||||
|
{ id: 'contactEmail', label: 'Email' },
|
||||||
|
{ id: 'kind', label: 'Type' },
|
||||||
|
{ id: 'triageState', label: 'Status' },
|
||||||
|
{ id: 'utmSource', label: 'UTM source' },
|
||||||
|
{ id: 'receivedAt', label: 'Received' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const INQUIRY_DEFAULT_HIDDEN: string[] = ['utmSource'];
|
||||||
|
|
||||||
|
interface GetColumnsOptions {
|
||||||
|
portSlug: string;
|
||||||
|
onTriage: (row: InquiryRow, state: InquiryTriageState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInquiryColumns({
|
||||||
|
portSlug,
|
||||||
|
onTriage,
|
||||||
|
}: GetColumnsOptions): ColumnDef<InquiryRow, unknown>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'contactName',
|
||||||
|
accessorKey: 'contactName',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/inquiries/${row.original.id}`}
|
||||||
|
className="truncate font-medium text-primary hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{row.original.contactName || '(no name)'}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contactEmail',
|
||||||
|
accessorKey: 'contactEmail',
|
||||||
|
header: 'Email',
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const email = getValue() as string | null;
|
||||||
|
return email ? (
|
||||||
|
<span className="text-sm">{email}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kind',
|
||||||
|
accessorKey: 'kind',
|
||||||
|
header: 'Type',
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const kind = getValue() as InquiryKind;
|
||||||
|
return <Badge className={KIND_TONE[kind]}>{KIND_LABELS[kind]}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'triageState',
|
||||||
|
accessorKey: 'triageState',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const state = row.original.triageState;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Badge className={TRIAGE_TONE[state]}>{TRIAGE_LABELS[state]}</Badge>
|
||||||
|
{row.original.convertedInterestId ? (
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/${row.original.convertedInterestId}`}
|
||||||
|
className="text-primary hover:underline text-xs"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
interest →
|
||||||
|
</Link>
|
||||||
|
) : row.original.convertedClientId ? (
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/clients/${row.original.convertedClientId}`}
|
||||||
|
className="text-primary hover:underline text-xs"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
client →
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'utmSource',
|
||||||
|
accessorKey: 'utmSource',
|
||||||
|
header: 'UTM source',
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const utm = getValue() as string | null;
|
||||||
|
return utm ? (
|
||||||
|
<span className="text-sm">{utm}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'receivedAt',
|
||||||
|
accessorKey: 'receivedAt',
|
||||||
|
header: 'Received',
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const iso = getValue() as string;
|
||||||
|
const d = new Date(iso);
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground text-sm tabular-nums" title={format(d, 'PPpp')}>
|
||||||
|
{formatDistanceToNowStrict(d, { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: '',
|
||||||
|
enableSorting: false,
|
||||||
|
size: 48,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isResolved =
|
||||||
|
row.original.triageState === 'converted' || row.original.triageState === 'dismissed';
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
aria-label={`Row actions for ${row.original.contactName ?? 'inquiry'}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/${portSlug}/inquiries/${row.original.id}`}>
|
||||||
|
<ExternalLink className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||||
|
Open
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{!isResolved ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem onClick={() => onTriage(row.original, 'assigned')}>
|
||||||
|
<UserCheck className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||||
|
Assign to me
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onTriage(row.original, 'dismissed')}>
|
||||||
|
<X className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||||
|
Dismiss
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem onClick={() => onTriage(row.original, 'open')}>
|
||||||
|
Reopen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
119
src/components/inquiries/inquiry-convert-actions.tsx
Normal file
119
src/components/inquiries/inquiry-convert-actions.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ArrowRight, UserPlus, UserCheck, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
|
interface InquiryConvertActionsProps {
|
||||||
|
portSlug: string;
|
||||||
|
inquiry: {
|
||||||
|
id: string;
|
||||||
|
triageState: string;
|
||||||
|
convertedClientId: string | null;
|
||||||
|
convertedInterestId: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryConvertActions({ portSlug, inquiry }: InquiryConvertActionsProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inquiries'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const convert = useMutation({
|
||||||
|
mutationFn: (target: 'client' | 'interest') =>
|
||||||
|
apiFetch<{ data: { clientId: string; interestId: string | null } }>(
|
||||||
|
`/api/v1/inquiries/${inquiry.id}/convert`,
|
||||||
|
{ method: 'POST', body: { target } },
|
||||||
|
),
|
||||||
|
onSuccess: (res, target) => {
|
||||||
|
invalidate();
|
||||||
|
if (target === 'interest' && res.data.interestId) {
|
||||||
|
toast.success('Converted to interest.');
|
||||||
|
router.push(`/${portSlug}/interests/${res.data.interestId}`);
|
||||||
|
} else {
|
||||||
|
toast.success('Converted to client.');
|
||||||
|
router.push(`/${portSlug}/clients/${res.data.clientId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => toastError(err, 'Convert failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const triage = useMutation({
|
||||||
|
mutationFn: (state: 'open' | 'assigned' | 'dismissed') =>
|
||||||
|
apiFetch(`/api/v1/inquiries/${inquiry.id}/triage`, { method: 'PATCH', body: { state } }),
|
||||||
|
onSuccess: (_d, state) => {
|
||||||
|
invalidate();
|
||||||
|
toast.success(`Marked ${state}.`);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => toastError(err, 'Update failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const busy = convert.isPending || triage.isPending;
|
||||||
|
const alreadyInterest = Boolean(inquiry.convertedInterestId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PermissionGate resource="inquiries" action="manage">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{alreadyInterest ? (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={`/${portSlug}/interests/${inquiry.convertedInterestId}`}>View interest</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" disabled={busy} onClick={() => convert.mutate('interest')}>
|
||||||
|
<ArrowRight className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Convert to interest
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inquiry.convertedClientId ? (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={`/${portSlug}/clients/${inquiry.convertedClientId}`}>View client</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => convert.mutate('client')}
|
||||||
|
>
|
||||||
|
<UserPlus className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Convert to client
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inquiry.triageState === 'open' ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => triage.mutate('assigned')}
|
||||||
|
>
|
||||||
|
<UserCheck className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Assign to me
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{inquiry.triageState !== 'dismissed' && inquiry.triageState !== 'converted' ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => triage.mutate('dismissed')}
|
||||||
|
>
|
||||||
|
<X className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</PermissionGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
src/components/inquiries/inquiry-detail.tsx
Normal file
197
src/components/inquiries/inquiry-detail.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
import { DetailLayout, type DetailTab } from '@/components/shared/detail-layout';
|
||||||
|
import { DetailNotFound } from '@/components/shared/detail-not-found';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
|
import {
|
||||||
|
KIND_LABELS,
|
||||||
|
TRIAGE_LABELS,
|
||||||
|
TRIAGE_TONE,
|
||||||
|
type InquiryKind,
|
||||||
|
type InquiryTriageState,
|
||||||
|
} from '@/components/inquiries/inquiry-columns';
|
||||||
|
import { InquiryConvertActions } from '@/components/inquiries/inquiry-convert-actions';
|
||||||
|
|
||||||
|
interface InquiryDetailData {
|
||||||
|
id: string;
|
||||||
|
kind: InquiryKind;
|
||||||
|
contactName: string | null;
|
||||||
|
contactEmail: string | null;
|
||||||
|
payload: Record<string, unknown> | null;
|
||||||
|
receivedAt: string;
|
||||||
|
sourceIp: string | null;
|
||||||
|
utmSource: string | null;
|
||||||
|
utmMedium: string | null;
|
||||||
|
utmCampaign: string | null;
|
||||||
|
triageState: InquiryTriageState;
|
||||||
|
triagedAt: string | null;
|
||||||
|
convertedClientId: string | null;
|
||||||
|
convertedInterestId: string | null;
|
||||||
|
convertedClient: { id: string; fullName: string } | null;
|
||||||
|
convertedInterest: { id: string; pipelineStage: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2 py-1.5 text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="min-w-0 break-words">
|
||||||
|
{value || <span className="text-muted-foreground">—</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryDetail({ id }: { id: string }) {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
const { isSuperAdmin } = usePermissions();
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery<InquiryDetailData>({
|
||||||
|
queryKey: ['inquiries', id],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: InquiryDetailData }>(`/api/v1/inquiries/${id}`).then((r) => r.data),
|
||||||
|
retry: (count, err) => {
|
||||||
|
const status = (err as { status?: number })?.status;
|
||||||
|
return status === 404 || status === 403 ? false : count < 2;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error && !isLoading) {
|
||||||
|
const status = (error as { status?: number })?.status;
|
||||||
|
return (
|
||||||
|
<DetailNotFound
|
||||||
|
entity="inquiry"
|
||||||
|
backHref={`/${portSlug}/inquiries`}
|
||||||
|
backLabel="Back to inquiries"
|
||||||
|
status={status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = (data?.payload ?? {}) as Record<string, unknown>;
|
||||||
|
const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : '');
|
||||||
|
// The free-text message a lead left. Website forms use different keys
|
||||||
|
// (contact form -> `comments`; others -> `message`/`comment`), so probe the
|
||||||
|
// common ones and surface it for every inquiry kind.
|
||||||
|
const comment = str('comments') || str('message') || str('comment') || str('notes');
|
||||||
|
|
||||||
|
const tabs: DetailTab[] = [
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
label: 'Overview',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<Row label="Name" value={data?.contactName} />
|
||||||
|
<Row label="Email" value={data?.contactEmail} />
|
||||||
|
<Row label="Phone" value={str('phone')} />
|
||||||
|
{data?.kind === 'residence_inquiry' ? (
|
||||||
|
<Row label="Place of residence" value={str('address')} />
|
||||||
|
) : null}
|
||||||
|
{data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null}
|
||||||
|
{comment ? (
|
||||||
|
<Row label="Message" value={<span className="whitespace-pre-wrap">{comment}</span>} />
|
||||||
|
) : null}
|
||||||
|
<Row label="Type" value={data ? KIND_LABELS[data.kind] : ''} />
|
||||||
|
<Row label="Received" value={data ? format(new Date(data.receivedAt), 'PPpp') : ''} />
|
||||||
|
<Row label="Source IP" value={data?.sourceIp} />
|
||||||
|
<Row label="UTM source" value={data?.utmSource} />
|
||||||
|
<Row label="UTM medium" value={data?.utmMedium} />
|
||||||
|
<Row label="UTM campaign" value={data?.utmCampaign} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tracking',
|
||||||
|
label: 'Tracking',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<Row
|
||||||
|
label="Status"
|
||||||
|
value={
|
||||||
|
data ? (
|
||||||
|
<Badge className={TRIAGE_TONE[data.triageState]}>
|
||||||
|
{TRIAGE_LABELS[data.triageState]}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Triaged at"
|
||||||
|
value={data?.triagedAt ? format(new Date(data.triagedAt), 'PPpp') : ''}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Converted client"
|
||||||
|
value={
|
||||||
|
data?.convertedClient ? (
|
||||||
|
<a
|
||||||
|
href={`/${portSlug}/clients/${data.convertedClient.id}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{data.convertedClient.fullName}
|
||||||
|
</a>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Converted interest"
|
||||||
|
value={
|
||||||
|
data?.convertedInterest ? (
|
||||||
|
<a
|
||||||
|
href={`/${portSlug}/interests/${data.convertedInterest.id}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View interest ({data.convertedInterest.pipelineStage})
|
||||||
|
</a>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'payload',
|
||||||
|
label: 'Raw payload',
|
||||||
|
content: (
|
||||||
|
<pre className="max-h-96 overflow-auto rounded-md bg-muted/40 p-3 text-xs">
|
||||||
|
{JSON.stringify(data?.payload ?? {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
].filter((tab) => tab.id !== 'payload' || isSuperAdmin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailLayout
|
||||||
|
isLoading={isLoading}
|
||||||
|
header={
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-xl font-semibold">{data?.contactName || '(no name)'}</h1>
|
||||||
|
{data ? (
|
||||||
|
<Badge className={TRIAGE_TONE[data.triageState]}>
|
||||||
|
{TRIAGE_LABELS[data.triageState]}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{data ? KIND_LABELS[data.kind] : ''} inquiry
|
||||||
|
{data?.contactEmail ? ` · ${data.contactEmail}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{data ? <InquiryConvertActions portSlug={portSlug} inquiry={data} /> : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
tabs={tabs}
|
||||||
|
defaultTab="overview"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/inquiries/inquiry-filters.tsx
Normal file
33
src/components/inquiries/inquiry-filters.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { FilterDefinition } from '@/components/shared/filter-bar';
|
||||||
|
|
||||||
|
export const inquiryFilterDefinitions: FilterDefinition[] = [
|
||||||
|
{
|
||||||
|
key: 'search',
|
||||||
|
label: 'Search',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Search name or email…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'kind',
|
||||||
|
label: 'Type',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'Berth', value: 'berth_inquiry' },
|
||||||
|
{ label: 'Residence', value: 'residence_inquiry' },
|
||||||
|
{ label: 'Contact', value: 'contact_form' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'state',
|
||||||
|
label: 'Status',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'Inbox (open + assigned)', value: 'inbox' },
|
||||||
|
{ label: 'Open', value: 'open' },
|
||||||
|
{ label: 'Assigned', value: 'assigned' },
|
||||||
|
{ label: 'Converted', value: 'converted' },
|
||||||
|
{ label: 'Dismissed', value: 'dismissed' },
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
127
src/components/inquiries/inquiry-list.tsx
Normal file
127
src/components/inquiries/inquiry-list.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
|
import { DataTable } from '@/components/shared/data-table';
|
||||||
|
import { FilterBar } from '@/components/shared/filter-bar';
|
||||||
|
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
|
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
import { inquiryFilterDefinitions } from '@/components/inquiries/inquiry-filters';
|
||||||
|
import {
|
||||||
|
getInquiryColumns,
|
||||||
|
INQUIRY_COLUMN_OPTIONS,
|
||||||
|
INQUIRY_DEFAULT_HIDDEN,
|
||||||
|
type InquiryRow,
|
||||||
|
type InquiryTriageState,
|
||||||
|
} from '@/components/inquiries/inquiry-columns';
|
||||||
|
import { InquiryCard } from '@/components/inquiries/inquiry-card';
|
||||||
|
|
||||||
|
export function InquiryList() {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { setChrome } = useMobileChrome();
|
||||||
|
useEffect(() => {
|
||||||
|
setChrome({ title: 'Inquiries', showBackButton: false });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [setChrome]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
pagination,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
sort,
|
||||||
|
setSort,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
filters,
|
||||||
|
setFilter,
|
||||||
|
clearFilters,
|
||||||
|
} = usePaginatedQuery<InquiryRow>({
|
||||||
|
queryKey: ['inquiries'],
|
||||||
|
endpoint: '/api/v1/inquiries',
|
||||||
|
initialSort: { field: 'receivedAt', direction: 'desc' },
|
||||||
|
filterDefinitions: inquiryFilterDefinitions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const triageMutation = useMutation({
|
||||||
|
mutationFn: (args: { id: string; state: InquiryTriageState }) =>
|
||||||
|
apiFetch(`/api/v1/inquiries/${args.id}/triage`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { state: args.state },
|
||||||
|
}),
|
||||||
|
onSuccess: (_d, vars) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inquiries'] });
|
||||||
|
toast.success(`Marked ${vars.state}.`);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => toastError(err, 'Update failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = getInquiryColumns({
|
||||||
|
portSlug,
|
||||||
|
onTriage: (row, state) => triageMutation.mutate({ id: row.id, state }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { hidden, setHidden } = useTablePreferences('inquiries', INQUIRY_DEFAULT_HIDDEN);
|
||||||
|
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title="Inquiries"
|
||||||
|
description="Submissions captured from the public marketing site (berth, residence, and contact forms)."
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<FilterBar
|
||||||
|
filters={inquiryFilterDefinitions}
|
||||||
|
values={filters}
|
||||||
|
onChange={setFilter}
|
||||||
|
onClear={clearFilters}
|
||||||
|
/>
|
||||||
|
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||||
|
<ColumnPicker columns={INQUIRY_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<TableSkeleton />
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
columnVisibility={columnVisibility}
|
||||||
|
data={data}
|
||||||
|
pagination={pagination}
|
||||||
|
onPaginationChange={(p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
}}
|
||||||
|
sort={sort}
|
||||||
|
onSortChange={setSort}
|
||||||
|
isLoading={isFetching && !isLoading}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
cardRender={(row) => <InquiryCard inquiry={row.original} portSlug={portSlug} />}
|
||||||
|
emptyState={
|
||||||
|
<EmptyState
|
||||||
|
title="No inquiries found"
|
||||||
|
description="Submissions from the marketing site will appear here."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,14 +11,11 @@ import {
|
|||||||
Trophy,
|
Trophy,
|
||||||
XCircle,
|
XCircle,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Mail,
|
|
||||||
MessageSquarePlus,
|
MessageSquarePlus,
|
||||||
Phone,
|
|
||||||
AlarmClock,
|
AlarmClock,
|
||||||
User,
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab';
|
import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab';
|
||||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -35,6 +32,7 @@ import { AssignedToChip } from '@/components/interests/assigned-to-chip';
|
|||||||
import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
|
import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
|
||||||
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
|
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
||||||
import { formatOutcome } from '@/lib/constants';
|
import { formatOutcome } from '@/lib/constants';
|
||||||
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -74,9 +72,9 @@ interface InterestDetailHeaderProps {
|
|||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
/** Primary contact channels resolved from the linked client. The header
|
/** Primary contact channels resolved from the linked client. The
|
||||||
* uses these to render Email / Call / WhatsApp buttons so the rep
|
* Email/Call/WhatsApp pills were removed (CM-4); these stay on the payload
|
||||||
* doesn't have to navigate to the client page just to reach out. */
|
* for downstream reuse (e.g. proxy comms routing, CM-9). */
|
||||||
clientPrimaryEmail?: string | null;
|
clientPrimaryEmail?: string | null;
|
||||||
clientPrimaryPhone?: string | null;
|
clientPrimaryPhone?: string | null;
|
||||||
clientPrimaryPhoneE164?: string | null;
|
clientPrimaryPhoneE164?: string | null;
|
||||||
@@ -144,21 +142,13 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
const [logContactOpen, setLogContactOpen] = useState(false);
|
const [logContactOpen, setLogContactOpen] = useState(false);
|
||||||
const [reminderOpen, setReminderOpen] = useState(false);
|
const [reminderOpen, setReminderOpen] = useState(false);
|
||||||
// (Upload-paper-signed-EOI dialog moved to the EOI tab.)
|
// (Upload-paper-signed-EOI dialog moved to the EOI tab.)
|
||||||
|
// CM-5: assignment UI is hidden when the per-port toggle is off (default).
|
||||||
|
const assignmentEnabled = useFeatureFlag('assignment_enabled', false);
|
||||||
|
|
||||||
const isArchived = !!interest.archivedAt;
|
const isArchived = !!interest.archivedAt;
|
||||||
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
||||||
const isClosed = !!interest.outcome;
|
const isClosed = !!interest.outcome;
|
||||||
|
|
||||||
// Contact deep-links - resolved from the linked client's primary channels.
|
|
||||||
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
|
|
||||||
// stripping non-digits from the display value when the canonical form is
|
|
||||||
// missing.
|
|
||||||
const whatsappNumber = interest.clientPrimaryPhoneE164
|
|
||||||
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
|
|
||||||
: interest.clientPrimaryPhone
|
|
||||||
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const reopenMutation = useMutation({
|
const reopenMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
|
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
|
||||||
@@ -285,13 +275,15 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
{interest.activeReminderCount}
|
{interest.activeReminderCount}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<PermissionGate resource="interests" action="edit">
|
{assignmentEnabled ? (
|
||||||
<AssignedToChip
|
<PermissionGate resource="interests" action="edit">
|
||||||
interestId={interest.id}
|
<AssignedToChip
|
||||||
currentAssignedTo={interest.assignedTo ?? null}
|
interestId={interest.id}
|
||||||
currentAssignedToName={interest.assignedToName ?? null}
|
currentAssignedTo={interest.assignedTo ?? null}
|
||||||
/>
|
currentAssignedToName={interest.assignedToName ?? null}
|
||||||
</PermissionGate>
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
) : null}
|
||||||
<MultiEoiChip interestId={interest.id} />
|
<MultiEoiChip interestId={interest.id} />
|
||||||
<DealPulseChip
|
<DealPulseChip
|
||||||
interest={{
|
interest={{
|
||||||
@@ -340,94 +332,38 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Contact deep-links - let the rep email / call / WhatsApp the
|
{/* CM-4: Email/Call/WhatsApp deep-links removed at client request.
|
||||||
client without leaving the interest workspace. Resolved from
|
Client-page link + Log-contact action stay - the rep can still
|
||||||
the linked client's primary contact channels (server-side
|
jump to the client and record outreach without leaving here. */}
|
||||||
fetch in getInterestById). */}
|
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
{interest.clientPrimaryEmail ||
|
{interest.clientId ? (
|
||||||
interest.clientPrimaryPhone ||
|
|
||||||
whatsappNumber ||
|
|
||||||
interest.clientId ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
|
||||||
{interest.clientId ? (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
href={`/${portSlug}/clients/${interest.clientId}` as any}
|
|
||||||
aria-label="Open client page"
|
|
||||||
>
|
|
||||||
<User />
|
|
||||||
Client page
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{interest.clientPrimaryEmail ? (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`mailto:${interest.clientPrimaryEmail}`}
|
|
||||||
aria-label={`Email ${interest.clientPrimaryEmail}`}
|
|
||||||
>
|
|
||||||
<Mail />
|
|
||||||
Email
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{interest.clientPrimaryPhone ? (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`tel:${interest.clientPrimaryPhone}`}
|
|
||||||
aria-label={`Call ${interest.clientPrimaryPhone}`}
|
|
||||||
>
|
|
||||||
<Phone />
|
|
||||||
Call
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{whatsappNumber ? (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`https://wa.me/${whatsappNumber}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={`Message on WhatsApp`}
|
|
||||||
>
|
|
||||||
<WhatsAppIcon className="h-4 w-4" />
|
|
||||||
WhatsApp
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
<Button
|
||||||
|
asChild
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||||
onClick={() => setLogContactOpen(true)}
|
|
||||||
aria-label="Log a contact for this interest"
|
|
||||||
>
|
>
|
||||||
<MessageSquarePlus />
|
<Link
|
||||||
Log contact
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/clients/${interest.clientId}` as any}
|
||||||
|
aria-label="Open client page"
|
||||||
|
>
|
||||||
|
<User />
|
||||||
|
Client page
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||||
|
onClick={() => setLogContactOpen(true)}
|
||||||
|
aria-label="Log a contact for this interest"
|
||||||
|
>
|
||||||
|
<MessageSquarePlus />
|
||||||
|
Log contact
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top-right actions. Won/Lost are sales-critical and read as text
|
{/* Top-right actions. Won/Lost are sales-critical and read as text
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from '@/components/ui/accordion';
|
} from '@/components/ui/accordion';
|
||||||
import { NotesList } from '@/components/shared/notes-list';
|
import { NotesList } from '@/components/shared/notes-list';
|
||||||
|
import { ProxyCard } from '@/components/shared/proxy-card';
|
||||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||||
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
|
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
|
||||||
import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
|
import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
|
||||||
@@ -848,7 +849,18 @@ function OverviewTab({
|
|||||||
deposit_paid: 'deposit',
|
deposit_paid: 'deposit',
|
||||||
contract: 'contract',
|
contract: 'contract',
|
||||||
};
|
};
|
||||||
const stageOwnedMilestone = STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
|
const stageOwnedMilestoneRaw =
|
||||||
|
STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
|
||||||
|
// B2 (2026-06-18): if the stage-owned milestone is already COMPLETE — e.g. a
|
||||||
|
// migrated deal left at stage=eoi with a signed EOI that never auto-advanced —
|
||||||
|
// don't pin it as the current "NEXT STEP". Falling back to null makes phaseFor
|
||||||
|
// use completion ordering, so the signed milestone shows as done/past and the
|
||||||
|
// next incomplete one (Reservation) becomes current. Display-only; the
|
||||||
|
// pipeline_stage column is unchanged.
|
||||||
|
const stageOwnedMilestoneComplete = stageOwnedMilestoneRaw
|
||||||
|
? milestoneCompletion[stageOwnedMilestoneRaw]
|
||||||
|
: false;
|
||||||
|
const stageOwnedMilestone = stageOwnedMilestoneComplete ? null : stageOwnedMilestoneRaw;
|
||||||
const stageOwnedIdx = stageOwnedMilestone ? order.indexOf(stageOwnedMilestone) : -1;
|
const stageOwnedIdx = stageOwnedMilestone ? order.indexOf(stageOwnedMilestone) : -1;
|
||||||
const phaseFor = (k: (typeof order)[number]): Phase => {
|
const phaseFor = (k: (typeof order)[number]): Phase => {
|
||||||
// Stage owns this milestone → always current, never collapsed.
|
// Stage owns this milestone → always current, never collapsed.
|
||||||
@@ -1122,6 +1134,9 @@ function OverviewTab({
|
|||||||
archivedAt={null}
|
archivedAt={null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* CM-9: per-deal point-of-contact (overrides the client's default). */}
|
||||||
|
<ProxyCard entityType="interest" entityId={interestId} />
|
||||||
|
|
||||||
{/* Qualification checklist - surfaces the port's per-port criteria so
|
{/* Qualification checklist - surfaces the port's per-port criteria so
|
||||||
the rep can mark each one confirmed before the deal advances out
|
the rep can mark each one confirmed before the deal advances out
|
||||||
of 'enquiry'. Hidden when the port has no enabled criteria. */}
|
of 'enquiry'. Hidden when the port has no enabled criteria. */}
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export interface LinkedBerthRow {
|
|||||||
addedBy: string | null;
|
addedBy: string | null;
|
||||||
addedAt: string;
|
addedAt: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
priceOverride: string | null;
|
||||||
|
priceOverrideCurrency: string | null;
|
||||||
mooringNumber: string | null;
|
mooringNumber: string | null;
|
||||||
area: string | null;
|
area: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -193,6 +195,24 @@ function useRemoveLink(interestId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CM-2 Part B: set/clear the deal-specific price override for one berth.
|
||||||
|
function useSetBerthPrice(interestId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (args: { berthId: string; price: number | null }) =>
|
||||||
|
apiFetch(`/api/v1/interests/${interestId}/berths/${args.berthId}/price`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { price: args.price },
|
||||||
|
}),
|
||||||
|
onSuccess: (_data, args) => {
|
||||||
|
toast.success(args.price == null ? 'Reverted to list price.' : 'Deal price saved.');
|
||||||
|
qc.invalidateQueries({ queryKey: ['interest-berths', interestId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toastError(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Bypass dialog ──────────────────────────────────────────────────────────
|
// ─── Bypass dialog ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface BypassDialogProps {
|
interface BypassDialogProps {
|
||||||
@@ -289,9 +309,20 @@ function LinkedBerthRowItem({
|
|||||||
}: RowProps) {
|
}: RowProps) {
|
||||||
const [bypassOpen, setBypassOpen] = useState(false);
|
const [bypassOpen, setBypassOpen] = useState(false);
|
||||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
|
const [priceDraft, setPriceDraft] = useState(row.priceOverride ?? '');
|
||||||
|
const setBerthPrice = useSetBerthPrice(interestId);
|
||||||
const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
|
const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
|
||||||
const showBypassControl = eoiStatus === 'signed';
|
const showBypassControl = eoiStatus === 'signed';
|
||||||
|
|
||||||
|
const commitPrice = () => {
|
||||||
|
const raw = priceDraft.replace(/[,\s]/g, '');
|
||||||
|
const next = raw === '' ? null : Number(raw);
|
||||||
|
if (next !== null && (!Number.isFinite(next) || next < 0)) return; // ignore garbage
|
||||||
|
const prev = row.priceOverride == null ? null : Number(row.priceOverride);
|
||||||
|
if (next === prev) return;
|
||||||
|
setBerthPrice.mutate({ berthId: row.berthId, price: next });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -458,6 +489,34 @@ function LinkedBerthRowItem({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{/* CM-2 Part B: deal-specific price. Overrides the berth's list price for
|
||||||
|
this interest only; flows into the EOI/document {{berth.price}} token. */}
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-3 border-t pt-3">
|
||||||
|
<div className="min-w-0 flex-1 space-y-0.5">
|
||||||
|
<p className="text-sm font-medium">Deal price</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Overrides the berth's list price for this deal only. Leave blank to use the list
|
||||||
|
price.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="w-36 rounded-md border px-2 py-1 text-sm tabular-nums"
|
||||||
|
placeholder="List price"
|
||||||
|
value={priceDraft}
|
||||||
|
disabled={isPending || setBerthPrice.isPending}
|
||||||
|
onChange={(e) => setPriceDraft(e.target.value)}
|
||||||
|
onBlur={commitPrice}
|
||||||
|
aria-label={`Deal price for ${row.mooringNumber ?? row.berthId}`}
|
||||||
|
/>
|
||||||
|
{row.priceOverrideCurrency ? (
|
||||||
|
<span className="text-xs text-muted-foreground">{row.priceOverrideCurrency}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showBypassControl ? (
|
{showBypassControl ? (
|
||||||
// Bypass section reads as a third toggle-style row: label + description
|
// Bypass section reads as a third toggle-style row: label + description
|
||||||
// on the left, action button inline with the description so it doesn't
|
// on the left, action button inline with the description so it doesn't
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
|
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
import { Topbar } from '@/components/layout/topbar';
|
import { Topbar } from '@/components/layout/topbar';
|
||||||
import { NavigationHistoryTracker } from '@/components/layout/navigation-history-tracker';
|
import { NavigationHistoryTracker } from '@/components/layout/navigation-history-tracker';
|
||||||
@@ -112,6 +114,30 @@ export function AppShell({
|
|||||||
const currentPortId = useUIStore((s) => s.currentPortId);
|
const currentPortId = useUIStore((s) => s.currentPortId);
|
||||||
const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null;
|
const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null;
|
||||||
|
|
||||||
|
// Residential lockdown: a residential-only user (residential access, no
|
||||||
|
// marina `clients.view`) must never see marina pages — including the marina
|
||||||
|
// dashboard. The API already 403s their data; this guard blocks the *routes*,
|
||||||
|
// redirecting any non-residential path to their residential home. Personal
|
||||||
|
// surfaces (settings, inbox) stay reachable.
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const residentialOnly =
|
||||||
|
!isSuperAdmin && can('residential_clients', 'view') && !can('clients', 'view');
|
||||||
|
useEffect(() => {
|
||||||
|
if (!residentialOnly || !pathname) return;
|
||||||
|
const [portSeg, ...rest] = pathname.split('/').filter(Boolean);
|
||||||
|
const sub = rest.join('/');
|
||||||
|
const allowed =
|
||||||
|
sub === '' ||
|
||||||
|
sub.startsWith('residential') ||
|
||||||
|
sub.startsWith('settings') ||
|
||||||
|
sub.startsWith('inbox');
|
||||||
|
if (!allowed && portSeg) {
|
||||||
|
router.replace(`/${portSeg}/residential/clients`);
|
||||||
|
}
|
||||||
|
}, [residentialOnly, pathname, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mqMobile = window.matchMedia(MOBILE_QUERY);
|
const mqMobile = window.matchMedia(MOBILE_QUERY);
|
||||||
const mqTablet = window.matchMedia(TABLET_QUERY);
|
const mqTablet = window.matchMedia(TABLET_QUERY);
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useNotifications } from '@/hooks/use-notifications';
|
import { useNotifications } from '@/hooks/use-notifications';
|
||||||
import { NotificationItem } from '@/components/notifications/notification-item';
|
import { NotificationItem } from '@/components/notifications/notification-item';
|
||||||
import { AlertCard, AlertCardEmpty } from '@/components/alerts/alert-card';
|
import { AlertCard, AlertCardEmpty } from '@/components/alerts/alert-card';
|
||||||
import { useAlertCount, useAlertList, useAlertRealtime } from '@/components/alerts/use-alerts';
|
import {
|
||||||
|
useAlertCount,
|
||||||
|
useAlertList,
|
||||||
|
useAlertRealtime,
|
||||||
|
useDismissAll,
|
||||||
|
} from '@/components/alerts/use-alerts';
|
||||||
|
|
||||||
interface NotificationListResponse {
|
interface NotificationListResponse {
|
||||||
data: Array<{
|
data: Array<{
|
||||||
@@ -66,6 +71,7 @@ export function Inbox() {
|
|||||||
const systemCritical = alertCount?.bySeverity.critical ?? 0;
|
const systemCritical = alertCount?.bySeverity.critical ?? 0;
|
||||||
const systemAlerts = alertList?.data ?? [];
|
const systemAlerts = alertList?.data ?? [];
|
||||||
const systemTop = systemAlerts.slice(0, 8);
|
const systemTop = systemAlerts.slice(0, 8);
|
||||||
|
const dismissAll = useDismissAll();
|
||||||
|
|
||||||
// ── Personal (notifications) ──
|
// ── Personal (notifications) ──
|
||||||
const { unreadCount: personalUnread } = useNotifications();
|
const { unreadCount: personalUnread } = useNotifications();
|
||||||
@@ -230,13 +236,25 @@ export function Inbox() {
|
|||||||
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Active alerts
|
Active alerts
|
||||||
</h4>
|
</h4>
|
||||||
<Link
|
<div className="flex items-center gap-3">
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
{systemAlerts.length > 0 ? (
|
||||||
href={portSlug ? (`/${portSlug}/alerts` as any) : ('/alerts' as any)}
|
<button
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
type="button"
|
||||||
>
|
onClick={() => dismissAll.mutate({})}
|
||||||
View all
|
disabled={dismissAll.isPending}
|
||||||
</Link>
|
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Dismiss all
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<Link
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
|
href={portSlug ? (`/${portSlug}/alerts` as any) : ('/alerts' as any)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<ScrollArea className="max-h-[400px]">
|
<ScrollArea className="max-h-[400px]">
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Anchor, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
|
import { Anchor, ClipboardList, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
|
|
||||||
type TabSpec = {
|
type TabSpec = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -12,16 +13,21 @@ type TabSpec = {
|
|||||||
segment: string; // route segment after /[portSlug]/
|
segment: string; // route segment after /[portSlug]/
|
||||||
};
|
};
|
||||||
|
|
||||||
// Left-of-center: Dashboard, Clients. Right-of-center: Berths, More.
|
// Marina users: Dashboard, Clients | Berths. Search center, More right.
|
||||||
// Search occupies the center slot. Documents demoted to the MoreSheet -
|
const MARINA_TABS_LEFT: TabSpec[] = [
|
||||||
// reps reach docs less often than berths during a walking inventory check,
|
|
||||||
// and pinned-to-client documents are accessed via the client detail anyway.
|
|
||||||
const TABS_LEFT: TabSpec[] = [
|
|
||||||
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
|
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
|
||||||
{ label: 'Clients', icon: Users, segment: 'clients' },
|
{ label: 'Clients', icon: Users, segment: 'clients' },
|
||||||
];
|
];
|
||||||
|
const MARINA_TABS_RIGHT: TabSpec[] = [{ label: 'Berths', icon: Anchor, segment: 'berths' }];
|
||||||
|
|
||||||
const TABS_RIGHT: TabSpec[] = [{ label: 'Berths', icon: Anchor, segment: 'berths' }];
|
// Residential-only users (e.g. residential partners) never have marina access,
|
||||||
|
// so the bottom tabs mirror their residential-only sidebar instead of showing
|
||||||
|
// Clients/Berths they 403 on (matches the AppShell route lockdown).
|
||||||
|
const RESIDENTIAL_TABS_LEFT: TabSpec[] = [
|
||||||
|
{ label: 'Clients', icon: Users, segment: 'residential/clients' },
|
||||||
|
{ label: 'Interests', icon: ClipboardList, segment: 'residential/interests' },
|
||||||
|
];
|
||||||
|
const RESIDENTIAL_TABS_RIGHT: TabSpec[] = [];
|
||||||
|
|
||||||
interface MobileBottomTabsProps {
|
interface MobileBottomTabsProps {
|
||||||
onMoreClick: () => void;
|
onMoreClick: () => void;
|
||||||
@@ -31,6 +37,11 @@ interface MobileBottomTabsProps {
|
|||||||
export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) {
|
export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
|
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
|
||||||
|
const { can, isSuperAdmin } = usePermissions();
|
||||||
|
const residentialOnly =
|
||||||
|
!isSuperAdmin && can('residential_clients', 'view') && !can('clients', 'view');
|
||||||
|
const tabsLeft = residentialOnly ? RESIDENTIAL_TABS_LEFT : MARINA_TABS_LEFT;
|
||||||
|
const tabsRight = residentialOnly ? RESIDENTIAL_TABS_RIGHT : MARINA_TABS_RIGHT;
|
||||||
|
|
||||||
function isActive(segment: string): boolean {
|
function isActive(segment: string): boolean {
|
||||||
return pathname.startsWith(`/${portSlug}/${segment}`);
|
return pathname.startsWith(`/${portSlug}/${segment}`);
|
||||||
@@ -46,7 +57,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
|
|||||||
'flex items-end',
|
'flex items-end',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{TABS_LEFT.map((tab) => (
|
{tabsLeft.map((tab) => (
|
||||||
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
|
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -60,7 +71,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
|
|||||||
<span className="relative font-medium">Search</span>
|
<span className="relative font-medium">Search</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{TABS_RIGHT.map((tab) => (
|
{tabsRight.map((tab) => (
|
||||||
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
|
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import { useMobileChrome } from './mobile-layout-provider';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixed mobile topbar (56px + safe-area top inset). Marina-editorial premium:
|
* Fixed mobile topbar (56px + safe-area top inset). Marina-editorial premium:
|
||||||
* deep-navy gradient surface with white type, the brand "PN" mark on the
|
* deep-navy gradient surface with white type, a back arrow on the left when
|
||||||
* left when there's no back affordance, and a soft glow shadow underneath
|
* there's a back affordance (otherwise a balancing spacer), and a soft glow
|
||||||
* for depth instead of a hard divider line.
|
* shadow underneath for depth instead of a hard divider line.
|
||||||
*
|
*
|
||||||
* Slots: title (auto-truncating), back arrow, primary action - all driven by
|
* Slots: title (auto-truncating), back arrow, primary action - all driven by
|
||||||
* `useMobileChrome()` from the active page. When no page has set a title the
|
* `useMobileChrome()` from the active page. When no page has set a title the
|
||||||
@@ -47,17 +47,6 @@ export function MobileTopbar() {
|
|||||||
portTitle ||
|
portTitle ||
|
||||||
'CRM';
|
'CRM';
|
||||||
|
|
||||||
// Brand-mark initials derived from the port slug
|
|
||||||
// ("port-nimara" → "PN", "marina-alpha" → "MA"). Cheap, self-contained,
|
|
||||||
// no extra DB round-trip.
|
|
||||||
const initials = portSlug
|
|
||||||
? portSlug
|
|
||||||
.split('-')
|
|
||||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
|
||||||
.join('')
|
|
||||||
.slice(0, 2)
|
|
||||||
: 'CR';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -71,15 +60,10 @@ export function MobileTopbar() {
|
|||||||
{backTarget ? (
|
{backTarget ? (
|
||||||
<BackButton variant="mobile" />
|
<BackButton variant="mobile" />
|
||||||
) : (
|
) : (
|
||||||
<div
|
// No back affordance on top-level pages. Render an empty spacer the
|
||||||
aria-label={portTitle || 'Home'}
|
// same width as the right-hand action slot so the centered title
|
||||||
className={cn(
|
// stays optically centered (the brand "PN" mark was removed here).
|
||||||
'size-9 shrink-0 rounded-lg flex items-center justify-center',
|
<div className="size-11 shrink-0" aria-hidden />
|
||||||
'bg-[#3a7bc8] shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_1px_2px_rgba(0,0,0,0.25)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-white font-bold text-[13px] tracking-tight">{initials}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h1
|
<h1
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Bookmark,
|
Bookmark,
|
||||||
Building2,
|
Building2,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
|
MailQuestion,
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
Home,
|
Home,
|
||||||
@@ -53,6 +54,7 @@ const MORE_GROUPS: MoreGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
|
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
|
||||||
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
||||||
|
{ label: 'Inquiries', icon: MailQuestion, segment: 'inquiries' },
|
||||||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||||||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||||||
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
|
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation';
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Users,
|
Users,
|
||||||
|
UsersRound,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Anchor,
|
Anchor,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
FileBarChart,
|
FileBarChart,
|
||||||
Inbox,
|
Inbox,
|
||||||
|
MailQuestion,
|
||||||
Camera,
|
Camera,
|
||||||
Globe,
|
Globe,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -112,9 +114,11 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
items: [
|
items: [
|
||||||
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
||||||
|
{ href: `${base}/client-groups`, label: 'Client Groups', icon: UsersRound },
|
||||||
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
|
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
|
||||||
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
||||||
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
||||||
|
{ href: `${base}/inquiries`, label: 'Inquiries', icon: MailQuestion },
|
||||||
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
|
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
|
||||||
{
|
{
|
||||||
href: `${base}/tenancies`,
|
href: `${base}/tenancies`,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
|||||||
import { NotesList } from '@/components/shared/notes-list';
|
import { NotesList } from '@/components/shared/notes-list';
|
||||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
||||||
import { SOURCES } from '@/lib/constants';
|
import { SOURCES } from '@/lib/constants';
|
||||||
|
|
||||||
interface ResidentialInterest {
|
interface ResidentialInterest {
|
||||||
@@ -95,6 +96,8 @@ function OverviewTab({
|
|||||||
stageOptions: Array<{ value: string; label: string }>;
|
stageOptions: Array<{ value: string; label: string }>;
|
||||||
}) {
|
}) {
|
||||||
const update = useInterestPatch(interestId);
|
const update = useInterestPatch(interestId);
|
||||||
|
// CM-5: residential assignment row hidden when the per-port toggle is off.
|
||||||
|
const assignmentEnabled = useFeatureFlag('assignment_enabled', false);
|
||||||
const save = (field: string) => async (next: string | null) => {
|
const save = (field: string) => async (next: string | null) => {
|
||||||
await update.mutateAsync({ [field]: next });
|
await update.mutateAsync({ [field]: next });
|
||||||
};
|
};
|
||||||
@@ -105,6 +108,7 @@ function OverviewTab({
|
|||||||
}>({
|
}>({
|
||||||
queryKey: ['residential-assignable-users'],
|
queryKey: ['residential-assignable-users'],
|
||||||
queryFn: () => apiFetch('/api/v1/residential/assignable-users'),
|
queryFn: () => apiFetch('/api/v1/residential/assignable-users'),
|
||||||
|
enabled: assignmentEnabled,
|
||||||
});
|
});
|
||||||
const assigneeOptions = (assignableUsers?.data ?? []).map((u) => ({
|
const assigneeOptions = (assignableUsers?.data ?? []).map((u) => ({
|
||||||
value: u.id,
|
value: u.id,
|
||||||
@@ -132,15 +136,17 @@ function OverviewTab({
|
|||||||
onSave={save('source')}
|
onSave={save('source')}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Assigned to">
|
{assignmentEnabled ? (
|
||||||
<InlineEditableField
|
<Row label="Assigned to">
|
||||||
variant="select"
|
<InlineEditableField
|
||||||
options={assigneeOptions}
|
variant="select"
|
||||||
value={interest.assignedTo}
|
options={assigneeOptions}
|
||||||
onSave={save('assignedTo')}
|
value={interest.assignedTo}
|
||||||
placeholder="Unassigned"
|
onSave={save('assignedTo')}
|
||||||
/>
|
placeholder="Unassigned"
|
||||||
</Row>
|
/>
|
||||||
|
</Row>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -320,9 +320,11 @@ interface ScanShellProps {
|
|||||||
* imagery. */
|
* imagery. */
|
||||||
logoUrl?: string | null;
|
logoUrl?: string | null;
|
||||||
portName?: string | null;
|
portName?: string | null;
|
||||||
|
/** CM-6: when true, skip ALL parsing - open an empty form for manual entry. */
|
||||||
|
manualEntry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
|
export function ScanShell({ logoUrl, portName, manualEntry = false }: ScanShellProps = {}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -351,6 +353,26 @@ export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
|
|||||||
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||||||
setImagePreview(URL.createObjectURL(file));
|
setImagePreview(URL.createObjectURL(file));
|
||||||
setCurrentFile(file);
|
setCurrentFile(file);
|
||||||
|
|
||||||
|
// CM-6: manual-entry mode - the port admin disabled scanning. Skip
|
||||||
|
// Tesseract AND the server call entirely; go straight to an empty form.
|
||||||
|
if (manualEntry) {
|
||||||
|
setState({
|
||||||
|
kind: 'verify',
|
||||||
|
parsed: {
|
||||||
|
establishment: null,
|
||||||
|
date: null,
|
||||||
|
amount: null,
|
||||||
|
currency: null,
|
||||||
|
lineItems: [],
|
||||||
|
confidence: 0,
|
||||||
|
},
|
||||||
|
source: 'manual',
|
||||||
|
reason: 'manual-mode',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState({ kind: 'processing', engine: 'tesseract' });
|
setState({ kind: 'processing', engine: 'tesseract' });
|
||||||
|
|
||||||
// Always run Tesseract first - it's free, on-device, and gives us a
|
// Always run Tesseract first - it's free, on-device, and gives us a
|
||||||
|
|||||||
249
src/components/shared/proxy-card.tsx
Normal file
249
src/components/shared/proxy-card.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Mail, Phone, UserCheck, UserPlus } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
|
type ProxyEntityType = 'client' | 'interest' | 'yacht';
|
||||||
|
|
||||||
|
interface Proxy {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
relationship: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOURCE: Record<ProxyEntityType, 'clients' | 'interests' | 'yachts'> = {
|
||||||
|
client: 'clients',
|
||||||
|
interest: 'interests',
|
||||||
|
yacht: 'yachts',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CM-9: point-of-contact ("proxy") panel for a client / interest / yacht detail
|
||||||
|
* page. Reads + edits the per-entity proxy via the entity's sub-resource route.
|
||||||
|
*/
|
||||||
|
export function ProxyCard({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
}: {
|
||||||
|
entityType: ProxyEntityType;
|
||||||
|
entityId: string;
|
||||||
|
}) {
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const canManage = can(RESOURCE[entityType], 'edit');
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const base = `/api/v1/${RESOURCE[entityType]}/${entityId}/proxy`;
|
||||||
|
const queryKey = ['proxy', entityType, entityId];
|
||||||
|
|
||||||
|
const { data } = useQuery<{ data: Proxy | null }>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => apiFetch(base),
|
||||||
|
});
|
||||||
|
const proxy = data?.data ?? null;
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: () => apiFetch(base, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Point of contact removed');
|
||||||
|
qc.invalidateQueries({ queryKey });
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h3 className="inline-flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||||
|
<UserCheck className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
|
Point of contact
|
||||||
|
</h3>
|
||||||
|
{canManage ? (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7" onClick={() => setOpen(true)}>
|
||||||
|
{proxy ? (
|
||||||
|
'Edit'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus className="me-1 h-3.5 w-3.5" aria-hidden />
|
||||||
|
Add
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{proxy ? (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{proxy.name}
|
||||||
|
{proxy.relationship ? (
|
||||||
|
<span className="ms-2 text-xs font-normal text-muted-foreground">
|
||||||
|
{proxy.relationship}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
{proxy.email ? (
|
||||||
|
<a
|
||||||
|
href={`mailto:${proxy.email}`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Mail className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{proxy.email}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{proxy.phone ? (
|
||||||
|
<p className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Phone className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{proxy.phone}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{proxy.notes ? <p className="text-xs text-muted-foreground">{proxy.notes}</p> : null}
|
||||||
|
{canManage ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove.mutate()}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
className="pt-1 text-xs text-destructive hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No proxy set — comms go to the {entityType} directly.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<ProxyDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
base={base}
|
||||||
|
existing={proxy}
|
||||||
|
entityType={entityType}
|
||||||
|
onSaved={() => qc.invalidateQueries({ queryKey })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProxyDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
base,
|
||||||
|
existing,
|
||||||
|
entityType,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
base: string;
|
||||||
|
existing: Proxy | null;
|
||||||
|
entityType: ProxyEntityType;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(existing?.name ?? '');
|
||||||
|
const [email, setEmail] = useState(existing?.email ?? '');
|
||||||
|
const [phone, setPhone] = useState(existing?.phone ?? '');
|
||||||
|
const [relationship, setRelationship] = useState(existing?.relationship ?? '');
|
||||||
|
const [notes, setNotes] = useState(existing?.notes ?? '');
|
||||||
|
// State seeds from `existing` at mount; the dialog is remounted on each open
|
||||||
|
// (the parent renders it conditionally), so no reseed effect is needed.
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiFetch(base, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { name: name.trim(), email, phone, relationship, notes },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Point of contact saved');
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Point of contact</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
A person who acts as the point of contact for this {entityType}. Used to address
|
||||||
|
outbound comms.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="proxy-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="proxy-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="proxy-phone">Phone</Label>
|
||||||
|
<Input id="proxy-phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="proxy-rel">Relationship (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy-rel"
|
||||||
|
placeholder="e.g. broker, spouse, assistant, legal"
|
||||||
|
value={relationship}
|
||||||
|
onChange={(e) => setRelationship(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="proxy-notes">Notes (optional)</Label>
|
||||||
|
<Input id="proxy-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => save.mutate()} disabled={!name.trim() || save.isPending}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -102,9 +102,11 @@ export function YachtCard({ yacht, portSlug, onEdit, onArchive }: YachtCardProps
|
|||||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Owner subtitle */}
|
{/* Owner subtitle. `flex min-w-0` (not inline-flex) so a long owner
|
||||||
|
name truncates within the card instead of overflowing ~11px on
|
||||||
|
the narrowest mobile widths (R2). */}
|
||||||
{yacht.currentOwnerName ? (
|
{yacht.currentOwnerName ? (
|
||||||
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
<p className="mt-0.5 flex min-w-0 items-center gap-1 text-sm text-muted-foreground">
|
||||||
<OwnerIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
<OwnerIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||||
<span className="truncate">{yacht.currentOwnerName}</span>
|
<span className="truncate">{yacht.currentOwnerName}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { TenancyCreateDialog } from '@/components/tenancies/tenancy-create-dialo
|
|||||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||||
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
|
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
|
||||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||||
|
import { ProxyCard } from '@/components/shared/proxy-card';
|
||||||
import { NotesList } from '@/components/shared/notes-list';
|
import { NotesList } from '@/components/shared/notes-list';
|
||||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||||
import { TenancyList, type TenancyRow } from '@/components/tenancies/tenancy-list';
|
import { TenancyList, type TenancyRow } from '@/components/tenancies/tenancy-list';
|
||||||
@@ -176,6 +177,10 @@ function OverviewTab({
|
|||||||
return (
|
return (
|
||||||
<FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
|
<FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* CM-9: per-vessel point-of-contact (overrides interest + client). */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<ProxyCard entityType="yacht" entityId={yachtId} />
|
||||||
|
</div>
|
||||||
{/* Identity */}
|
{/* Identity */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ export interface OnboardingStatusPayload {
|
|||||||
* and the admin checklist summary. Cached for 60s so all three surfaces
|
* and the admin checklist summary. Cached for 60s so all three surfaces
|
||||||
* share a single fetch on first paint.
|
* share a single fetch on first paint.
|
||||||
*
|
*
|
||||||
* Pass `enabled=false` to skip the network call (e.g. when the current
|
* Defaults to OFF: the endpoint is admin-only (admin.manage_settings), so
|
||||||
* user isn't a super_admin and the surface won't render anyway).
|
* callers must opt in with `enabled: true` once they've confirmed the user is
|
||||||
|
* a super_admin. This prevents a transient 403 (e.g. a stale `isSuperAdmin`
|
||||||
|
* during permission hydration) from firing the privileged request for
|
||||||
|
* non-admins.
|
||||||
*/
|
*/
|
||||||
export function useOnboardingStatus(opts: { enabled?: boolean } = {}) {
|
export function useOnboardingStatus(opts: { enabled?: boolean } = {}) {
|
||||||
return useQuery<OnboardingStatusPayload>({
|
return useQuery<OnboardingStatusPayload>({
|
||||||
@@ -38,7 +41,7 @@ export function useOnboardingStatus(opts: { enabled?: boolean } = {}) {
|
|||||||
(r) => r.data,
|
(r) => r.data,
|
||||||
),
|
),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
enabled: opts.enabled ?? true,
|
enabled: opts.enabled === true,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/lib/api/proxy-route-handlers.ts
Normal file
56
src/lib/api/proxy-route-handlers.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* CM-9: shared GET/PUT/DELETE handlers for the per-entity proxy sub-resource
|
||||||
|
* (`/api/v1/{clients|interests|yachts}/[id]/proxy`). Each entity's route.ts
|
||||||
|
* binds these with its own permission resource so we reuse existing
|
||||||
|
* clients/interests/yachts gating instead of a new permission.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { clearProxy, getProxy, setProxy } from '@/lib/services/proxies.service';
|
||||||
|
import { setProxySchema, type ProxyEntityType } from '@/lib/validators/proxies';
|
||||||
|
|
||||||
|
export function makeProxyHandlers(entityType: ProxyEntityType) {
|
||||||
|
const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const proxy = await getProxy(ctx.portId, entityType, params.id!);
|
||||||
|
return NextResponse.json({ data: proxy });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const putHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, setProxySchema);
|
||||||
|
const proxy = await setProxy(ctx.portId, entityType, params.id!, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: proxy });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
await clearProxy(ctx.portId, entityType, params.id!, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { getHandler, putHandler, deleteHandler };
|
||||||
|
}
|
||||||
62
src/lib/auth/account-setup-email.ts
Normal file
62
src/lib/auth/account-setup-email.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { BrandingShell } from '@/lib/email/shell';
|
||||||
|
|
||||||
|
import { consumePendingWelcome } from './pending-welcome';
|
||||||
|
|
||||||
|
interface AuthBranding {
|
||||||
|
appName?: string | null;
|
||||||
|
logoUrl?: string | null;
|
||||||
|
backgroundUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the email body for better-auth's `sendResetPassword` callback,
|
||||||
|
* choosing between two framings of the same set-password link:
|
||||||
|
*
|
||||||
|
* - a unique **welcome** email when the recipient was flagged by the admin
|
||||||
|
* "create user" flow (a brand-new user has nothing to reset), or
|
||||||
|
* - the standard **password-reset** email for genuine self-service resets.
|
||||||
|
*
|
||||||
|
* Pure aside from rendering — no SMTP, no DB — so the welcome-vs-reset routing
|
||||||
|
* is directly unit-testable.
|
||||||
|
*/
|
||||||
|
export async function buildAccountPasswordEmail(opts: {
|
||||||
|
email: string;
|
||||||
|
name?: string | null;
|
||||||
|
url: string;
|
||||||
|
appName: string;
|
||||||
|
authBranding: AuthBranding | null;
|
||||||
|
}): Promise<{ subject: string; html: string; text: string }> {
|
||||||
|
const emailBranding: BrandingShell | null = opts.authBranding
|
||||||
|
? {
|
||||||
|
logoUrl: opts.authBranding.logoUrl ?? null,
|
||||||
|
backgroundUrl: opts.authBranding.backgroundUrl ?? null,
|
||||||
|
primaryColor: null,
|
||||||
|
emailHeaderHtml: null,
|
||||||
|
emailFooterHtml: null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (consumePendingWelcome(opts.email)) {
|
||||||
|
const { crmWelcomeEmail } = await import('@/lib/email/templates/crm-welcome');
|
||||||
|
return crmWelcomeEmail(
|
||||||
|
{ link: opts.url, recipientName: opts.name ?? undefined, appName: opts.appName },
|
||||||
|
{ branding: emailBranding },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { renderShell, safeUrl } = await import('@/lib/email/shell');
|
||||||
|
const subject = `Reset your ${opts.appName} password`;
|
||||||
|
const safeName = (opts.name || 'there').replace(/[<>&]/g, '');
|
||||||
|
const body = `
|
||||||
|
<p style="margin-bottom:16px;">Hi ${safeName},</p>
|
||||||
|
<p style="margin-bottom:16px;">You requested a password reset for your ${opts.appName} account.</p>
|
||||||
|
<p style="margin-bottom:16px;">
|
||||||
|
<a href="${safeUrl(opts.url)}" style="color:#2563eb;font-weight:600;">Click here to set a new password</a>
|
||||||
|
- the link expires in 1 hour.
|
||||||
|
</p>
|
||||||
|
<p style="color:#64748b;">If you didn't request this, you can safely ignore this email.</p>
|
||||||
|
`;
|
||||||
|
const html = renderShell({ title: subject, body, branding: emailBranding });
|
||||||
|
const text = `Reset your password: ${opts.url}`;
|
||||||
|
return { subject, html, text };
|
||||||
|
}
|
||||||
@@ -77,42 +77,28 @@ function buildAuth() {
|
|||||||
// through the shared SMTP infra so EMAIL_REDIRECT_TO honours it
|
// through the shared SMTP infra so EMAIL_REDIRECT_TO honours it
|
||||||
// in dev.
|
// in dev.
|
||||||
sendResetPassword: async ({ user, url }) => {
|
sendResetPassword: async ({ user, url }) => {
|
||||||
const [{ sendEmail }, { renderShell, safeUrl }, { resolveAuthShellBranding }] =
|
const [{ sendEmail }, { resolveAuthShellBranding }, { buildAccountPasswordEmail }] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
import('@/lib/email'),
|
import('@/lib/email'),
|
||||||
import('@/lib/email/shell'),
|
|
||||||
import('@/lib/email/auth-shell-branding'),
|
import('@/lib/email/auth-shell-branding'),
|
||||||
|
import('@/lib/auth/account-setup-email'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const branding = await resolveAuthShellBranding();
|
const branding = await resolveAuthShellBranding();
|
||||||
const appName = branding?.appName ?? 'CRM';
|
const appName = branding?.appName ?? 'CRM';
|
||||||
const subject = `Reset your ${appName} password`;
|
|
||||||
const safeName = (user.name || 'there').replace(/[<>&]/g, '');
|
|
||||||
const body = `
|
|
||||||
<p style="margin-bottom:16px;">Hi ${safeName},</p>
|
|
||||||
<p style="margin-bottom:16px;">You requested a password reset for your ${appName} account.</p>
|
|
||||||
<p style="margin-bottom:16px;">
|
|
||||||
<a href="${safeUrl(url)}" style="color:#2563eb;font-weight:600;">Click here to set a new password</a>
|
|
||||||
- the link expires in 1 hour.
|
|
||||||
</p>
|
|
||||||
<p style="color:#64748b;">If you didn't request this, you can safely ignore this email.</p>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const html = renderShell({
|
// Admin-created users ride the same reset-token machinery but should
|
||||||
title: subject,
|
// receive a welcome email, not a "you requested a reset" one — the
|
||||||
body,
|
// create-user service marks them just before triggering this. The
|
||||||
branding: branding
|
// builder picks welcome-vs-reset and renders accordingly.
|
||||||
? {
|
const mail = await buildAccountPasswordEmail({
|
||||||
logoUrl: branding.logoUrl,
|
email: user.email,
|
||||||
backgroundUrl: branding.backgroundUrl,
|
name: user.name,
|
||||||
primaryColor: null,
|
url,
|
||||||
emailHeaderHtml: null,
|
appName,
|
||||||
emailFooterHtml: null,
|
authBranding: branding,
|
||||||
}
|
|
||||||
: null,
|
|
||||||
});
|
});
|
||||||
const text = `Reset your password: ${url}`;
|
await sendEmail(user.email, mail.subject, mail.html, undefined, mail.text);
|
||||||
await sendEmail(user.email, subject, html, undefined, text);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
29
src/lib/auth/pending-welcome.ts
Normal file
29
src/lib/auth/pending-welcome.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Bridges the admin "create user" flow to better-auth's single
|
||||||
|
* `sendResetPassword` callback.
|
||||||
|
*
|
||||||
|
* A brand-new admin-created user is provisioned with a throwaway password and
|
||||||
|
* then sent a set-password link via better-auth's password-reset machinery — but
|
||||||
|
* the *email* should read as a welcome, not a "you requested a reset". better-auth
|
||||||
|
* exposes only one `sendResetPassword` callback (no per-call context), so the
|
||||||
|
* create-user service marks the recipient here immediately before triggering the
|
||||||
|
* reset; the callback consumes the mark and renders the welcome email instead.
|
||||||
|
*
|
||||||
|
* Mark and consume happen in the same process within a single synchronous
|
||||||
|
* request flow (`createUser` awaits `requestPasswordReset`, which awaits the
|
||||||
|
* callback), so this module-level set is safe — it is never read across requests.
|
||||||
|
* Keyed by lowercased email; distinct recipients never collide.
|
||||||
|
*/
|
||||||
|
const pendingWelcome = new Set<string>();
|
||||||
|
|
||||||
|
export function markPendingWelcome(email: string): void {
|
||||||
|
pendingWelcome.add(email.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true (and clears the mark) when this email was flagged as a welcome. */
|
||||||
|
export function consumePendingWelcome(email: string): boolean {
|
||||||
|
const key = email.toLowerCase();
|
||||||
|
const had = pendingWelcome.has(key);
|
||||||
|
pendingWelcome.delete(key);
|
||||||
|
return had;
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export type PermissionAction<R extends PermissionResource> = keyof RolePermissio
|
|||||||
* (audit finding L23).
|
* (audit finding L23).
|
||||||
*/
|
*/
|
||||||
export const PERMISSION_CATALOG = {
|
export const PERMISSION_CATALOG = {
|
||||||
clients: ['view', 'create', 'edit', 'delete', 'merge', 'export'],
|
clients: ['view', 'create', 'edit', 'delete', 'merge', 'export', 'gdpr_export'],
|
||||||
interests: [
|
interests: [
|
||||||
'view',
|
'view',
|
||||||
'create',
|
'create',
|
||||||
@@ -69,6 +69,8 @@ export const PERMISSION_CATALOG = {
|
|||||||
],
|
],
|
||||||
residential_clients: ['view', 'create', 'edit', 'delete'],
|
residential_clients: ['view', 'create', 'edit', 'delete'],
|
||||||
residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
|
residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
|
||||||
|
inquiries: ['view', 'manage'],
|
||||||
|
client_groups: ['view', 'manage'],
|
||||||
} as const satisfies {
|
} as const satisfies {
|
||||||
[R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>;
|
[R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -332,13 +332,27 @@ export function formatSource(source: string | null | undefined): string | null {
|
|||||||
export const ROLE_LABELS: Record<string, string> = {
|
export const ROLE_LABELS: Record<string, string> = {
|
||||||
super_admin: 'Super Admin',
|
super_admin: 'Super Admin',
|
||||||
director: 'Director',
|
director: 'Director',
|
||||||
sales_manager: 'Sales Manager',
|
// Single sales role for the deployment — `sales_manager` is the full-access
|
||||||
|
// sales map, surfaced to users simply as "Sales". `sales_agent` is retired
|
||||||
|
// from selection (see NON_ASSIGNABLE_ROLE_NAMES) but keeps its label for any
|
||||||
|
// legacy assignment still rendered.
|
||||||
|
sales_manager: 'Sales',
|
||||||
sales_agent: 'Sales Agent',
|
sales_agent: 'Sales Agent',
|
||||||
finance_manager: 'Finance Manager',
|
finance_manager: 'Finance Manager',
|
||||||
viewer: 'Viewer',
|
viewer: 'Viewer',
|
||||||
residential_partner: 'Residential Partner',
|
residential_partner: 'Residential Partner',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System roles that must not appear as choices when assigning a role to a
|
||||||
|
* user. `super_admin` is platform-owner-only (minted via the invitation
|
||||||
|
* flow's isSuperAdmin gate, never the role dropdown); `sales_agent` is
|
||||||
|
* superseded by the single "Sales" role. The create/edit user form still
|
||||||
|
* surfaces a user's *current* role even if it's listed here, so existing
|
||||||
|
* assignments stay editable.
|
||||||
|
*/
|
||||||
|
export const NON_ASSIGNABLE_ROLE_NAMES = new Set(['super_admin', 'sales_agent']);
|
||||||
|
|
||||||
/** Returns the human label for a stored role name. Falls back to a
|
/** Returns the human label for a stored role name. Falls back to a
|
||||||
* Title-Case rendering for legacy / custom roles. */
|
* Title-Case rendering for legacy / custom roles. */
|
||||||
export function formatRole(role: string | null | undefined): string {
|
export function formatRole(role: string | null | undefined): string {
|
||||||
|
|||||||
25
src/lib/db/migrations/0092_inquiries_permission.sql
Normal file
25
src/lib/db/migrations/0092_inquiries_permission.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- 0092_inquiries_permission.sql
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- New `inquiries` permission resource (view/manage) backing the top-level
|
||||||
|
-- Inquiries workbench (previously the inbox lived under /admin and was gated on
|
||||||
|
-- admin.view_audit_log, which sales roles don't have).
|
||||||
|
--
|
||||||
|
-- Existing role rows are backfilled so the resource defaults to whatever the
|
||||||
|
-- role's `clients` access is: view ⟵ clients.view, manage ⟵ clients.create.
|
||||||
|
-- This lights up the right roles (anyone who can see/create clients) without a
|
||||||
|
-- manual per-role edit, and defaults to deny for read-only roles.
|
||||||
|
--
|
||||||
|
-- New-key only and idempotent via the `? 'inquiries'` guard, so re-running is a
|
||||||
|
-- no-op. Per-user / port-role override tables are intentionally left untouched:
|
||||||
|
-- the deep-merge resolver fills missing leaves from the base role (same
|
||||||
|
-- reasoning as 0041).
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = permissions || jsonb_build_object(
|
||||||
|
'inquiries', jsonb_build_object(
|
||||||
|
'view', COALESCE((permissions->'clients'->>'view')::boolean, false),
|
||||||
|
'manage', COALESCE((permissions->'clients'->>'create')::boolean, false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE permissions IS NOT NULL
|
||||||
|
AND NOT (permissions ? 'inquiries');
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- 0093_website_submissions_inquiry_cols.sql
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Inquiries workbench: tracking + display columns on website_submissions.
|
||||||
|
-- converted_client_id / converted_interest_id - set when an operator converts
|
||||||
|
-- an inquiry into CRM entities (FK to clients/interests).
|
||||||
|
-- contact_name / contact_email - extracted from the JSONB payload at capture
|
||||||
|
-- time so the list view can search/sort/display via real columns.
|
||||||
|
--
|
||||||
|
-- Idempotent: ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS + a
|
||||||
|
-- COALESCE backfill that only fills nulls. Safe to re-run.
|
||||||
|
|
||||||
|
ALTER TABLE website_submissions
|
||||||
|
ADD COLUMN IF NOT EXISTS converted_client_id text REFERENCES clients(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS converted_interest_id text REFERENCES interests(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS contact_name text,
|
||||||
|
ADD COLUMN IF NOT EXISTS contact_email text;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ws_contact_email
|
||||||
|
ON website_submissions (port_id, contact_email);
|
||||||
|
|
||||||
|
-- Backfill display columns from existing payloads (only where still null).
|
||||||
|
UPDATE website_submissions
|
||||||
|
SET contact_email = COALESCE(contact_email, NULLIF(payload->>'email', '')),
|
||||||
|
contact_name = COALESCE(
|
||||||
|
contact_name,
|
||||||
|
NULLIF(TRIM(CONCAT_WS(' ', payload->>'first_name', payload->>'last_name')), ''),
|
||||||
|
NULLIF(payload->>'name', ''),
|
||||||
|
NULLIF(payload->>'fullName', ''),
|
||||||
|
NULLIF(payload->>'full_name', '')
|
||||||
|
)
|
||||||
|
WHERE contact_email IS NULL OR contact_name IS NULL;
|
||||||
52
src/lib/db/migrations/0094_client_groups.sql
Normal file
52
src/lib/db/migrations/0094_client_groups.sql
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
-- 0094_client_groups.sql
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- CM-1: first-class client groups (mailing/segment lists) + the membership
|
||||||
|
-- join, plus the new `client_groups` permission resource (view/manage).
|
||||||
|
--
|
||||||
|
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + a guarded role backfill.
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
-- ─── 1. client_groups (per-port named group) ────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS client_groups (
|
||||||
|
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text,
|
||||||
|
color text NOT NULL DEFAULT '#6B7280',
|
||||||
|
mailchimp_tag text,
|
||||||
|
archived_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_client_groups_port ON client_groups(port_id);
|
||||||
|
-- Per-port, case-insensitive name uniqueness among non-archived groups.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_client_groups_port_name
|
||||||
|
ON client_groups(port_id, lower(name))
|
||||||
|
WHERE archived_at IS NULL;
|
||||||
|
|
||||||
|
-- ─── 2. client_group_members (M2M join; carries port_id for tenant isolation) ─
|
||||||
|
CREATE TABLE IF NOT EXISTS client_group_members (
|
||||||
|
group_id text NOT NULL REFERENCES client_groups(id) ON DELETE CASCADE,
|
||||||
|
client_id text NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||||
|
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (group_id, client_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cgm_client ON client_group_members(client_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cgm_port ON client_group_members(port_id);
|
||||||
|
|
||||||
|
-- ─── 3. `client_groups` permission resource (view/manage) ────────────────────
|
||||||
|
-- New-key only + idempotent via the `? 'client_groups'` guard. Defaults to the
|
||||||
|
-- role's clients access (view ⟵ clients.view, manage ⟵ clients.create) so the
|
||||||
|
-- right roles light up without a manual per-role edit.
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = permissions || jsonb_build_object(
|
||||||
|
'client_groups', jsonb_build_object(
|
||||||
|
'view', COALESCE((permissions->'clients'->>'view')::boolean, false),
|
||||||
|
'manage', COALESCE((permissions->'clients'->>'create')::boolean, false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE permissions IS NOT NULL
|
||||||
|
AND NOT (permissions ? 'client_groups');
|
||||||
24
src/lib/db/migrations/0095_proxies.sql
Normal file
24
src/lib/db/migrations/0095_proxies.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- 0095_proxies.sql
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- CM-9: per-entity point-of-contact ("proxy") attachable to a client, interest,
|
||||||
|
-- or yacht. At most one per entity; outbound comms resolve the most specific
|
||||||
|
-- via yacht → interest → client. entity_id is polymorphic (no FK; validated in
|
||||||
|
-- the service against the right table). Idempotent — safe to re-run.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS proxies (
|
||||||
|
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||||
|
entity_type text NOT NULL,
|
||||||
|
entity_id text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
email text,
|
||||||
|
phone text,
|
||||||
|
relationship text,
|
||||||
|
notes text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uniq_proxies_entity ON proxies(port_id, entity_type, entity_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_proxies_entity ON proxies(entity_type, entity_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_proxies_port ON proxies(port_id);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- CM-2 Part B: per-interest, per-berth deal-price override.
|
||||||
|
-- Null = use the berth's canonical list price (berths.price). When set, this
|
||||||
|
-- supersedes the list price for THIS interest's generated documents
|
||||||
|
-- (resolved in eoi-context via resolveBerthPriceForInterest).
|
||||||
|
ALTER TABLE interest_berths
|
||||||
|
ADD COLUMN IF NOT EXISTS price_override numeric,
|
||||||
|
ADD COLUMN IF NOT EXISTS price_override_currency text;
|
||||||
14
src/lib/db/migrations/0097_director_role_full_sales.sql
Normal file
14
src/lib/db/migrations/0097_director_role_full_sales.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Director becomes a senior-title twin of the single "Sales" role: identical
|
||||||
|
-- capabilities, no admin/settings access (admin stays Super-Admin-only).
|
||||||
|
--
|
||||||
|
-- The bootstrap seed inserts system roles with ON CONFLICT DO NOTHING, so
|
||||||
|
-- editing DIRECTOR_PERMISSIONS in seed-permissions.ts only affects fresh seeds.
|
||||||
|
-- Existing deployments need this data update to bring the stored `director`
|
||||||
|
-- row in line. Idempotent: re-running simply re-copies the sales map.
|
||||||
|
UPDATE roles AS d
|
||||||
|
SET permissions = sm.permissions,
|
||||||
|
description = 'Senior sales title. Full sales access, no admin/settings (Super-Admin only).',
|
||||||
|
updated_at = now()
|
||||||
|
FROM roles AS sm
|
||||||
|
WHERE d.name = 'director'
|
||||||
|
AND sm.name = 'sales_manager';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- New toggleable permission: clients.gdpr_export (trigger + download a client's
|
||||||
|
-- GDPR data export). Previously the export routes were gated by
|
||||||
|
-- admin.manage_settings, which sales roles lack. This grants it to the
|
||||||
|
-- sales-capable system roles by default and makes it an explicit (off) toggle
|
||||||
|
-- everywhere else, so admins can withhold it per-user (which hides the button).
|
||||||
|
--
|
||||||
|
-- Existing role rows store permissions as jsonb, so editing the seed/role maps
|
||||||
|
-- alone won't reach them — this backfills the key. Idempotent.
|
||||||
|
|
||||||
|
-- Sales-capable system roles get it ON by default.
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'true'::jsonb, true),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE name IN ('super_admin', 'director', 'sales_manager', 'sales_agent')
|
||||||
|
AND permissions ? 'clients';
|
||||||
|
|
||||||
|
-- Every other role that has a clients block but not the key yet defaults to OFF,
|
||||||
|
-- so the permission surfaces as an explicit toggle in the matrix.
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'false'::jsonb, true),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE permissions ? 'clients'
|
||||||
|
AND NOT (permissions -> 'clients' ? 'gdpr_export');
|
||||||
73
src/lib/db/schema/client-groups.ts
Normal file
73
src/lib/db/schema/client-groups.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Client groups (CM-1) - first-class mailing/segment groups for clients.
|
||||||
|
*
|
||||||
|
* A `client_groups` row is a named, per-port group (e.g. a mailing list).
|
||||||
|
* `client_group_members` is the M2M join to `clients`. Membership carries its
|
||||||
|
* own `port_id` for defense-in-depth tenant isolation (same doctrine as the
|
||||||
|
* document-folders aggregated projection - port_id at every join).
|
||||||
|
*
|
||||||
|
* Optional Mailchimp mapping lives on the group row: `mailchimpTag` is the
|
||||||
|
* tag/segment name pushed to the port's single Mailchimp audience. Null until
|
||||||
|
* an admin wires Mailchimp up (the integration is inert without creds).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { index, pgTable, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
import { clients } from './clients';
|
||||||
|
import { ports } from './ports';
|
||||||
|
|
||||||
|
export const clientGroups = pgTable(
|
||||||
|
'client_groups',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
portId: text('port_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
/** Chip color in the CRM UI. */
|
||||||
|
color: text('color').notNull().default('#6B7280'),
|
||||||
|
/** CM-1 Mailchimp: the tag/segment name this group maps to in the port's
|
||||||
|
* single Mailchimp audience. Null = not synced. */
|
||||||
|
mailchimpTag: text('mailchimp_tag'),
|
||||||
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('idx_client_groups_port').on(table.portId),
|
||||||
|
// Per-port, case-insensitive name uniqueness among non-archived groups.
|
||||||
|
uniqueIndex('idx_client_groups_port_name')
|
||||||
|
.on(table.portId, sql`lower(${table.name})`)
|
||||||
|
.where(sql`${table.archivedAt} IS NULL`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const clientGroupMembers = pgTable(
|
||||||
|
'client_group_members',
|
||||||
|
{
|
||||||
|
groupId: text('group_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => clientGroups.id, { onDelete: 'cascade' }),
|
||||||
|
clientId: text('client_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||||
|
portId: text('port_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
primaryKey({ columns: [table.groupId, table.clientId] }),
|
||||||
|
index('idx_cgm_client').on(table.clientId),
|
||||||
|
index('idx_cgm_port').on(table.portId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ClientGroup = typeof clientGroups.$inferSelect;
|
||||||
|
export type NewClientGroup = typeof clientGroups.$inferInsert;
|
||||||
|
export type ClientGroupMember = typeof clientGroupMembers.$inferSelect;
|
||||||
|
export type NewClientGroupMember = typeof clientGroupMembers.$inferInsert;
|
||||||
@@ -7,6 +7,12 @@ export * from './users';
|
|||||||
// Clients
|
// Clients
|
||||||
export * from './clients';
|
export * from './clients';
|
||||||
|
|
||||||
|
// Client groups (CM-1 - mailing/segment groups)
|
||||||
|
export * from './client-groups';
|
||||||
|
|
||||||
|
// Proxies / points-of-contact (CM-9 - polymorphic across client/interest/yacht)
|
||||||
|
export * from './proxies';
|
||||||
|
|
||||||
// Companies
|
// Companies
|
||||||
export * from './companies';
|
export * from './companies';
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export type AlertSeverity = 'info' | 'warning' | 'critical';
|
|||||||
export const ALERT_RULES = [
|
export const ALERT_RULES = [
|
||||||
'reservation.no_agreement',
|
'reservation.no_agreement',
|
||||||
'interest.stale',
|
'interest.stale',
|
||||||
|
'interest.no_activity',
|
||||||
'document.signer_overdue',
|
'document.signer_overdue',
|
||||||
'berth.under_offer_stalled',
|
'berth.under_offer_stalled',
|
||||||
'expense.duplicate',
|
'expense.duplicate',
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ export const interestBerths = pgTable(
|
|||||||
addedBy: text('added_by'),
|
addedBy: text('added_by'),
|
||||||
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
|
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
notes: text('notes'),
|
notes: text('notes'),
|
||||||
|
// CM-2 Part B: deal-specific price for THIS (interest, berth). Null = use
|
||||||
|
// the berth's canonical list price. Does not touch berths.price.
|
||||||
|
priceOverride: numeric('price_override'),
|
||||||
|
priceOverrideCurrency: text('price_override_currency'),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId),
|
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId),
|
||||||
|
|||||||
48
src/lib/db/schema/proxies.ts
Normal file
48
src/lib/db/schema/proxies.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Proxies / points-of-contact (CM-9).
|
||||||
|
*
|
||||||
|
* A `proxy` is a designated contact person who acts on behalf of an entity.
|
||||||
|
* Polymorphic: attachable to a `client` (default), an `interest` (per-deal
|
||||||
|
* override), or a `yacht` (per-vessel override). At most one proxy per entity
|
||||||
|
* (unique index). Outbound comms resolve the most specific proxy via the chain
|
||||||
|
* yacht → interest → client (see resolveEffectiveProxy in proxies.service).
|
||||||
|
*
|
||||||
|
* `entity_id` is polymorphic (no FK) — validated against the right table in the
|
||||||
|
* service, same pattern as polymorphic yacht ownership / notes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { index, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
import { ports } from './ports';
|
||||||
|
|
||||||
|
export const proxies = pgTable(
|
||||||
|
'proxies',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
portId: text('port_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||||
|
/** 'client' | 'interest' | 'yacht' */
|
||||||
|
entityType: text('entity_type').notNull(),
|
||||||
|
entityId: text('entity_id').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
email: text('email'),
|
||||||
|
phone: text('phone'),
|
||||||
|
/** Free-form relationship label, e.g. broker / spouse / assistant / legal. */
|
||||||
|
relationship: text('relationship'),
|
||||||
|
notes: text('notes'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
// At most one proxy per entity.
|
||||||
|
uniqueIndex('uniq_proxies_entity').on(table.portId, table.entityType, table.entityId),
|
||||||
|
index('idx_proxies_entity').on(table.entityType, table.entityId),
|
||||||
|
index('idx_proxies_port').on(table.portId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Proxy = typeof proxies.$inferSelect;
|
||||||
|
export type NewProxy = typeof proxies.$inferInsert;
|
||||||
@@ -11,6 +11,9 @@ export type RolePermissions = {
|
|||||||
delete: boolean;
|
delete: boolean;
|
||||||
merge: boolean;
|
merge: boolean;
|
||||||
export: boolean;
|
export: boolean;
|
||||||
|
/** Trigger + download a GDPR data export for a client. Toggleable so it
|
||||||
|
* can be hidden from a user (e.g. a sales rep) when withheld. */
|
||||||
|
gdpr_export: boolean;
|
||||||
};
|
};
|
||||||
interests: {
|
interests: {
|
||||||
view: boolean;
|
view: boolean;
|
||||||
@@ -162,6 +165,14 @@ export type RolePermissions = {
|
|||||||
delete: boolean;
|
delete: boolean;
|
||||||
change_stage: boolean;
|
change_stage: boolean;
|
||||||
};
|
};
|
||||||
|
inquiries: {
|
||||||
|
view: boolean;
|
||||||
|
manage: boolean;
|
||||||
|
};
|
||||||
|
client_groups: {
|
||||||
|
view: boolean;
|
||||||
|
manage: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ export const websiteSubmissions = pgTable(
|
|||||||
* same form submission. Useful for reconciling: pick any submission
|
* same form submission. Useful for reconciling: pick any submission
|
||||||
* here, look up the matching NocoDB row, confirm both halves agree. */
|
* here, look up the matching NocoDB row, confirm both halves agree. */
|
||||||
legacyNocodbId: text('legacy_nocodb_id'),
|
legacyNocodbId: text('legacy_nocodb_id'),
|
||||||
|
/** Contact name + email extracted from `payload` at capture time so the
|
||||||
|
* inquiry list can search/sort/display via real columns (payload stays
|
||||||
|
* JSONB and isn't searched directly). Populated by the capture endpoint
|
||||||
|
* and backfilled in migration 0093. */
|
||||||
|
contactName: text('contact_name'),
|
||||||
|
contactEmail: text('contact_email'),
|
||||||
|
/** Set when an operator converts this inquiry into CRM entities. FK enforced
|
||||||
|
* at the DB level (migration 0093); typed as plain text here to avoid a
|
||||||
|
* circular schema import — `clients`/`interests` already reference
|
||||||
|
* `website_submissions`. */
|
||||||
|
convertedClientId: text('converted_client_id'),
|
||||||
|
convertedInterestId: text('converted_interest_id'),
|
||||||
/** Capture-time metadata for debugging. */
|
/** Capture-time metadata for debugging. */
|
||||||
sourceIp: text('source_ip'),
|
sourceIp: text('source_ip'),
|
||||||
userAgent: text('user_agent'),
|
userAgent: text('user_agent'),
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export async function seedBootstrap(): Promise<BootstrappedPort[]> {
|
|||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: 'director',
|
name: 'director',
|
||||||
description: 'Operational admin within assigned port(s). Can manage users and settings.',
|
description: 'Senior sales title. Full sales access, no admin/settings (Super-Admin only).',
|
||||||
permissions: DIRECTOR_PERMISSIONS,
|
permissions: DIRECTOR_PERMISSIONS,
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
|
|||||||
@@ -12,7 +12,15 @@
|
|||||||
import type { RolePermissions } from './schema/users';
|
import type { RolePermissions } from './schema/users';
|
||||||
|
|
||||||
export const ALL_PERMISSIONS: RolePermissions = {
|
export const ALL_PERMISSIONS: RolePermissions = {
|
||||||
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
clients: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: true,
|
||||||
|
merge: true,
|
||||||
|
export: true,
|
||||||
|
gdpr_export: true,
|
||||||
|
},
|
||||||
interests: {
|
interests: {
|
||||||
view: true,
|
view: true,
|
||||||
create: true,
|
create: true,
|
||||||
@@ -88,89 +96,31 @@ export const ALL_PERMISSIONS: RolePermissions = {
|
|||||||
delete: true,
|
delete: true,
|
||||||
change_stage: true,
|
change_stage: true,
|
||||||
},
|
},
|
||||||
|
inquiries: {
|
||||||
|
view: true,
|
||||||
|
manage: true,
|
||||||
|
},
|
||||||
|
client_groups: {
|
||||||
|
view: true,
|
||||||
|
manage: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
// DIRECTOR_PERMISSIONS is defined just below SALES_MANAGER_PERMISSIONS — it is a
|
||||||
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
// senior-title twin of the single "Sales" role with identical capabilities and
|
||||||
interests: {
|
// no admin/settings access (reserved for Super Admin). Kept there so it can
|
||||||
view: true,
|
// reference the sales map directly.
|
||||||
create: true,
|
|
||||||
edit: true,
|
|
||||||
delete: true,
|
|
||||||
change_stage: true,
|
|
||||||
override_stage: true,
|
|
||||||
generate_eoi: true,
|
|
||||||
export: true,
|
|
||||||
},
|
|
||||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true, update_prices: true },
|
|
||||||
documents: {
|
|
||||||
view: true,
|
|
||||||
create: true,
|
|
||||||
edit: true,
|
|
||||||
send_for_signing: true,
|
|
||||||
upload_signed: true,
|
|
||||||
delete: true,
|
|
||||||
manage_folders: true,
|
|
||||||
},
|
|
||||||
expenses: {
|
|
||||||
view: true,
|
|
||||||
create: true,
|
|
||||||
edit: true,
|
|
||||||
delete: true,
|
|
||||||
export: true,
|
|
||||||
scan_receipt: true,
|
|
||||||
},
|
|
||||||
invoices: {
|
|
||||||
view: true,
|
|
||||||
create: true,
|
|
||||||
edit: true,
|
|
||||||
delete: true,
|
|
||||||
send: true,
|
|
||||||
record_payment: true,
|
|
||||||
export: true,
|
|
||||||
},
|
|
||||||
payments: { view: true, record: true, delete: true },
|
|
||||||
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
|
||||||
email: { view: true, send: true, configure_account: true },
|
|
||||||
reminders: {
|
|
||||||
view_own: true,
|
|
||||||
view_all: true,
|
|
||||||
create: true,
|
|
||||||
edit_own: true,
|
|
||||||
edit_all: true,
|
|
||||||
assign_others: true,
|
|
||||||
},
|
|
||||||
calendar: { connect: true, view_events: true },
|
|
||||||
reports: { view_dashboard: true, view_analytics: true, export: true },
|
|
||||||
document_templates: { view: true, generate: true, manage: true },
|
|
||||||
yachts: { view: true, create: true, edit: true, delete: true, transfer: true },
|
|
||||||
companies: { view: true, create: true, edit: true, delete: true },
|
|
||||||
memberships: { view: true, manage: true },
|
|
||||||
tenancies: { view: true, manage: true, cancel: true },
|
|
||||||
admin: {
|
|
||||||
manage_users: true,
|
|
||||||
view_audit_log: true,
|
|
||||||
manage_settings: true,
|
|
||||||
manage_webhooks: true,
|
|
||||||
manage_reports: true,
|
|
||||||
manage_custom_fields: true,
|
|
||||||
manage_forms: true,
|
|
||||||
manage_tags: true,
|
|
||||||
system_backup: false,
|
|
||||||
permanently_delete_clients: false,
|
|
||||||
},
|
|
||||||
residential_clients: { view: true, create: true, edit: true, delete: true },
|
|
||||||
residential_interests: {
|
|
||||||
view: true,
|
|
||||||
create: true,
|
|
||||||
edit: true,
|
|
||||||
delete: true,
|
|
||||||
change_stage: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||||
clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true },
|
clients: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: false,
|
||||||
|
merge: true,
|
||||||
|
export: true,
|
||||||
|
gdpr_export: true,
|
||||||
|
},
|
||||||
interests: {
|
interests: {
|
||||||
view: true,
|
view: true,
|
||||||
create: true,
|
create: true,
|
||||||
@@ -246,10 +196,31 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
|||||||
delete: false,
|
delete: false,
|
||||||
change_stage: false,
|
change_stage: false,
|
||||||
},
|
},
|
||||||
|
inquiries: {
|
||||||
|
view: true,
|
||||||
|
manage: true,
|
||||||
|
},
|
||||||
|
client_groups: {
|
||||||
|
view: true,
|
||||||
|
manage: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Director is now a senior-title twin of the single "Sales" role: identical
|
||||||
|
// capabilities, no admin/settings access (admin stays Super-Admin-only). It
|
||||||
|
// remains a distinct, selectable role purely so the title can differ.
|
||||||
|
export const DIRECTOR_PERMISSIONS: RolePermissions = SALES_MANAGER_PERMISSIONS;
|
||||||
|
|
||||||
export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||||
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true },
|
clients: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: false,
|
||||||
|
merge: false,
|
||||||
|
export: true,
|
||||||
|
gdpr_export: true,
|
||||||
|
},
|
||||||
interests: {
|
interests: {
|
||||||
view: true,
|
view: true,
|
||||||
create: true,
|
create: true,
|
||||||
@@ -325,10 +296,26 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
|||||||
delete: false,
|
delete: false,
|
||||||
change_stage: false,
|
change_stage: false,
|
||||||
},
|
},
|
||||||
|
inquiries: {
|
||||||
|
view: true,
|
||||||
|
manage: true,
|
||||||
|
},
|
||||||
|
client_groups: {
|
||||||
|
view: true,
|
||||||
|
manage: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VIEWER_PERMISSIONS: RolePermissions = {
|
export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||||
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false },
|
clients: {
|
||||||
|
view: true,
|
||||||
|
create: false,
|
||||||
|
edit: false,
|
||||||
|
delete: false,
|
||||||
|
merge: false,
|
||||||
|
export: false,
|
||||||
|
gdpr_export: false,
|
||||||
|
},
|
||||||
interests: {
|
interests: {
|
||||||
view: true,
|
view: true,
|
||||||
create: false,
|
create: false,
|
||||||
@@ -410,13 +397,29 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
|
|||||||
delete: false,
|
delete: false,
|
||||||
change_stage: false,
|
change_stage: false,
|
||||||
},
|
},
|
||||||
|
inquiries: {
|
||||||
|
view: true,
|
||||||
|
manage: false,
|
||||||
|
},
|
||||||
|
client_groups: {
|
||||||
|
view: true,
|
||||||
|
manage: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Residential Partner - for an outside party who handles residential
|
// Residential Partner - for an outside party who handles residential
|
||||||
// inquiries on the marina's behalf. Sees only the residential pages and
|
// inquiries on the marina's behalf. Sees only the residential pages and
|
||||||
// nothing else; can't see marina clients, yachts, berths, EOIs, etc.
|
// nothing else; can't see marina clients, yachts, berths, EOIs, etc.
|
||||||
export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||||
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
|
clients: {
|
||||||
|
view: false,
|
||||||
|
create: false,
|
||||||
|
edit: false,
|
||||||
|
delete: false,
|
||||||
|
merge: false,
|
||||||
|
export: false,
|
||||||
|
gdpr_export: false,
|
||||||
|
},
|
||||||
interests: {
|
interests: {
|
||||||
view: false,
|
view: false,
|
||||||
create: false,
|
create: false,
|
||||||
@@ -498,4 +501,12 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
|||||||
delete: false,
|
delete: false,
|
||||||
change_stage: true,
|
change_stage: true,
|
||||||
},
|
},
|
||||||
|
inquiries: {
|
||||||
|
view: false,
|
||||||
|
manage: false,
|
||||||
|
},
|
||||||
|
client_groups: {
|
||||||
|
view: false,
|
||||||
|
manage: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
125
src/lib/email/templates/crm-welcome.tsx
Normal file
125
src/lib/email/templates/crm-welcome.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Button, Hr, Link, Text, render } from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
brandingPrimaryColor,
|
||||||
|
emailStyle,
|
||||||
|
renderShell,
|
||||||
|
safeUrl,
|
||||||
|
type BrandingShell,
|
||||||
|
} from '@/lib/email/shell';
|
||||||
|
|
||||||
|
interface WelcomeData {
|
||||||
|
/** The set-password link (better-auth reset URL landing on /set-password). */
|
||||||
|
link: string;
|
||||||
|
recipientName?: string;
|
||||||
|
/** Human label of the role the account was created with (e.g. "Sales"). */
|
||||||
|
roleName?: string;
|
||||||
|
/** Full product / app name as branded — e.g. "Port Nimara CRM". Falls back
|
||||||
|
* to "Port Nimara CRM". Used verbatim (no " CRM" is appended). */
|
||||||
|
appName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderOpts {
|
||||||
|
branding?: BrandingShell | null;
|
||||||
|
subject?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WelcomeBody({
|
||||||
|
appName,
|
||||||
|
link,
|
||||||
|
recipientName,
|
||||||
|
roleName,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
appName: string;
|
||||||
|
link: string;
|
||||||
|
recipientName?: string;
|
||||||
|
roleName?: string;
|
||||||
|
accent: string;
|
||||||
|
}) {
|
||||||
|
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome aboard,';
|
||||||
|
const roleClause = roleName ? ` with the ${roleName} role` : '';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text style={emailStyle.title(accent)}>Welcome to {appName}</Text>
|
||||||
|
<Text style={emailStyle.paragraph}>{greeting}</Text>
|
||||||
|
<Text style={emailStyle.paragraph}>
|
||||||
|
An account has been created for you in {appName}
|
||||||
|
{roleClause}. To get started, set your password using the button below — then sign in with
|
||||||
|
this email address.
|
||||||
|
</Text>
|
||||||
|
<div style={emailStyle.buttonRow}>
|
||||||
|
<Button href={safeUrl(link)} style={emailStyle.button(accent)}>
|
||||||
|
Set your password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Text style={emailStyle.signoff}>
|
||||||
|
See you inside,
|
||||||
|
<br />
|
||||||
|
<strong>The {appName} Team</strong>
|
||||||
|
</Text>
|
||||||
|
<Hr style={emailStyle.divider} />
|
||||||
|
<Text style={emailStyle.finePrint}>
|
||||||
|
For security this link will expire after a short while. If it's no longer valid, ask
|
||||||
|
your administrator to send you a new one.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
If the button doesn't work, paste this link into your browser:
|
||||||
|
<br />
|
||||||
|
<Link
|
||||||
|
href={safeUrl(link)}
|
||||||
|
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{link}
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welcome / set-your-password email for an admin-created CRM user. Distinct from
|
||||||
|
* the self-service "Reset your password" email — a brand-new user has nothing to
|
||||||
|
* reset. Wraps the same better-auth reset link that the /set-password page
|
||||||
|
* consumes, but in onboarding framing.
|
||||||
|
*/
|
||||||
|
export async function crmWelcomeEmail(
|
||||||
|
data: WelcomeData,
|
||||||
|
overrides?: RenderOpts,
|
||||||
|
): Promise<{ subject: string; html: string; text: string }> {
|
||||||
|
const appName = data.appName ?? 'Port Nimara CRM';
|
||||||
|
const subject = overrides?.subject?.trim()
|
||||||
|
? overrides.subject
|
||||||
|
: `Welcome to ${appName} — set your password`;
|
||||||
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
|
|
||||||
|
const body = await render(
|
||||||
|
<WelcomeBody
|
||||||
|
appName={appName}
|
||||||
|
link={data.link}
|
||||||
|
recipientName={data.recipientName}
|
||||||
|
roleName={data.roleName}
|
||||||
|
accent={accent}
|
||||||
|
/>,
|
||||||
|
{ pretty: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
`Welcome to ${appName}`,
|
||||||
|
'',
|
||||||
|
`An account has been created for you${data.roleName ? ` with the ${data.roleName} role` : ''}.`,
|
||||||
|
`Set your password to get started: ${data.link}`,
|
||||||
|
'',
|
||||||
|
`For security this link will expire after a short while — if it's no longer valid, ask your administrator to send a new one.`,
|
||||||
|
'',
|
||||||
|
`See you inside,`,
|
||||||
|
`The ${appName} Team`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -32,6 +32,46 @@ function daysAgo(n: number): Date {
|
|||||||
return new Date(Date.now() - n * DAY_MS);
|
return new Date(Date.now() - n * DAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── shared interest-activity fragments ───────────────────────────────────────
|
||||||
|
// Correlated subqueries keyed on `interests.id`, reused by the interest rules.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the interest was created by the legacy→CRM bulk import. The
|
||||||
|
* migration ledger is the only reliable marker (no column on `interests`).
|
||||||
|
*/
|
||||||
|
const isImportedSql = sql`EXISTS (
|
||||||
|
SELECT 1 FROM migration_source_links msl
|
||||||
|
WHERE msl.source_system = 'nocodb_interests'
|
||||||
|
AND msl.target_entity_type = 'interest'
|
||||||
|
AND msl.target_entity_id = ${interests.id}
|
||||||
|
)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a real user has worked the interest in-system: a logged contact, a
|
||||||
|
* note, or an UPDATE audit by a real user. The initial create-audit is excluded
|
||||||
|
* (action='update' only) so a bare, never-touched creation does not count.
|
||||||
|
*/
|
||||||
|
const hasFollowupSql = sql`(
|
||||||
|
EXISTS (SELECT 1 FROM interest_contact_log icl WHERE icl.interest_id = ${interests.id})
|
||||||
|
OR EXISTS (SELECT 1 FROM interest_notes inn WHERE inn.interest_id = ${interests.id})
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM audit_logs al
|
||||||
|
WHERE al.entity_type = 'interest' AND al.entity_id = ${interests.id}
|
||||||
|
AND al.user_id IS NOT NULL AND al.action = 'update'
|
||||||
|
)
|
||||||
|
)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most recent genuine in-system touch, used as the staleness clock. Coalesced to
|
||||||
|
* '-infinity' so GREATEST never returns NULL.
|
||||||
|
*/
|
||||||
|
const lastTouchAtSql = sql`GREATEST(
|
||||||
|
COALESCE(${interests.dateLastContact}, '-infinity'::timestamptz),
|
||||||
|
COALESCE((SELECT max(icl.occurred_at) FROM interest_contact_log icl WHERE icl.interest_id = ${interests.id}), '-infinity'::timestamptz),
|
||||||
|
COALESCE((SELECT max(inn.created_at) FROM interest_notes inn WHERE inn.interest_id = ${interests.id}), '-infinity'::timestamptz),
|
||||||
|
COALESCE((SELECT max(al.created_at) FROM audit_logs al WHERE al.entity_type='interest' AND al.entity_id=${interests.id} AND al.user_id IS NOT NULL AND al.action='update'), '-infinity'::timestamptz)
|
||||||
|
)`;
|
||||||
|
|
||||||
// ─── reservation.no_agreement ─────────────────────────────────────────────────
|
// ─── reservation.no_agreement ─────────────────────────────────────────────────
|
||||||
// Active reservations > 3 days old that have no reservation_agreement document
|
// Active reservations > 3 days old that have no reservation_agreement document
|
||||||
// in any non-cancelled state.
|
// in any non-cancelled state.
|
||||||
@@ -70,22 +110,18 @@ async function reservationNoAgreement(portId: string): Promise<AlertCandidate[]>
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mid-funnel stages where silence is a problem. EOI / reservation / deposit /
|
||||||
|
// contract stages have their own dedicated alerts (eoi.unsigned_long,
|
||||||
|
// reservation.no_agreement, etc.), so these rules sit before signing kicks in.
|
||||||
|
const ACTIVE_EARLY_STAGES = ['enquiry', 'qualified', 'nurturing'];
|
||||||
|
|
||||||
// ─── interest.stale ───────────────────────────────────────────────────────────
|
// ─── interest.stale ───────────────────────────────────────────────────────────
|
||||||
// Pipeline stuck in mid-funnel stages with no contact for 14+ days.
|
// A lead a user actually WORKED in-system (logged a contact / note / made an
|
||||||
|
// update) that has since gone quiet for 14+ days. Interests that were merely
|
||||||
|
// imported and never touched are handled by interest.no_activity, not here — so
|
||||||
|
// the bulk-import backlog never lands in this rule.
|
||||||
|
|
||||||
async function interestStale(portId: string): Promise<AlertCandidate[]> {
|
async function interestStale(portId: string): Promise<AlertCandidate[]> {
|
||||||
// Mid-funnel stages where silence is a problem. EOI / reservation /
|
|
||||||
// deposit / contract stages have their own dedicated alerts
|
|
||||||
// (eoi.unsigned_long, reservation.no_agreement, deposit_overdue, etc.),
|
|
||||||
// so this alert sits before signing kicks in.
|
|
||||||
//
|
|
||||||
// 2026-05-14 pipeline-refactor sweep: the prior values
|
|
||||||
// ('details_sent', 'in_communication', 'eoi_sent') were collapsed by
|
|
||||||
// migration 0062 into the 7-stage canon (enquiry / qualified /
|
|
||||||
// nurturing / eoi / ...). Until this fix landed, this alert never
|
|
||||||
// fired because no row in the new schema carried the dead stage
|
|
||||||
// strings.
|
|
||||||
const STALE_STAGES = ['enquiry', 'qualified', 'nurturing'];
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: interests.id,
|
id: interests.id,
|
||||||
@@ -97,25 +133,10 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(interests.portId, portId),
|
eq(interests.portId, portId),
|
||||||
inArray(interests.pipelineStage, STALE_STAGES),
|
inArray(interests.pipelineStage, ACTIVE_EARLY_STAGES),
|
||||||
isNull(interests.archivedAt),
|
isNull(interests.archivedAt),
|
||||||
// An interest can't be "stale for 14+ days" if it has only existed in
|
sql`${hasFollowupSql}`,
|
||||||
// THIS system for less than 14 days. Without this floor, a bulk import
|
sql`${lastTouchAtSql} < now() - interval '14 days'`,
|
||||||
// (which backdates dateLastContact to the legacy value) instantly flags
|
|
||||||
// every migrated interest as stale and floods the alert rail.
|
|
||||||
//
|
|
||||||
// We floor on updatedAt, NOT createdAt: the legacy→CRM migration
|
|
||||||
// backfilled created_at to each interest's real origination date (so
|
|
||||||
// analytics date-ranges work), which would make every migrated row look
|
|
||||||
// 14+ days old and re-open the flood. updated_at is left at the
|
|
||||||
// migration timestamp, so it's the reliable "entered/last-touched this
|
|
||||||
// system" clock — migrated rows stay suppressed for 14 days, then the
|
|
||||||
// contact-based OR below governs.
|
|
||||||
lt(interests.updatedAt, daysAgo(14)),
|
|
||||||
or(
|
|
||||||
lt(interests.dateLastContact, daysAgo(14)),
|
|
||||||
and(isNull(interests.dateLastContact), lt(interests.updatedAt, daysAgo(14))),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -123,7 +144,7 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
|
|||||||
ruleId: 'interest.stale',
|
ruleId: 'interest.stale',
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
title: `Stale interest: ${r.clientName}`,
|
title: `Stale interest: ${r.clientName}`,
|
||||||
body: `In '${STAGE_LABELS[r.stage as PipelineStage] ?? r.stage.replace(/_/g, ' ')}' with no contact for 14+ days.`,
|
body: `In '${STAGE_LABELS[r.stage as PipelineStage] ?? r.stage.replace(/_/g, ' ')}' — worked but no activity for 14+ days.`,
|
||||||
link: `/[port]/interests/${r.id}`,
|
link: `/[port]/interests/${r.id}`,
|
||||||
entityType: 'interest',
|
entityType: 'interest',
|
||||||
entityId: r.id,
|
entityId: r.id,
|
||||||
@@ -131,6 +152,42 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── interest.no_activity ─────────────────────────────────────────────────────
|
||||||
|
// A brand-new inbound interest nobody has touched in-system, 14+ days after it
|
||||||
|
// arrived. Excludes bulk-imported rows (those live in migration_source_links)
|
||||||
|
// so the historical backlog never nags.
|
||||||
|
|
||||||
|
async function interestNoActivity(portId: string): Promise<AlertCandidate[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: interests.id,
|
||||||
|
stage: interests.pipelineStage,
|
||||||
|
clientName: sql<string>`coalesce((SELECT full_name FROM clients WHERE id = ${interests.clientId}), 'unknown')`,
|
||||||
|
})
|
||||||
|
.from(interests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interests.portId, portId),
|
||||||
|
inArray(interests.pipelineStage, ACTIVE_EARLY_STAGES),
|
||||||
|
isNull(interests.archivedAt),
|
||||||
|
lt(interests.createdAt, daysAgo(14)),
|
||||||
|
sql`NOT ${hasFollowupSql}`,
|
||||||
|
sql`NOT ${isImportedSql}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
ruleId: 'interest.no_activity',
|
||||||
|
severity: 'info',
|
||||||
|
title: `New inquiry untouched: ${r.clientName}`,
|
||||||
|
body: `In '${STAGE_LABELS[r.stage as PipelineStage] ?? r.stage.replace(/_/g, ' ')}' — no activity since it arrived 14+ days ago.`,
|
||||||
|
link: `/[port]/interests/${r.id}`,
|
||||||
|
entityType: 'interest',
|
||||||
|
entityId: r.id,
|
||||||
|
metadata: { stage: r.stage },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── document.signer_overdue ──────────────────────────────────────────────────
|
// ─── document.signer_overdue ──────────────────────────────────────────────────
|
||||||
// Pending signer for >14d, last reminder >7d ago (or never).
|
// Pending signer for >14d, last reminder >7d ago (or never).
|
||||||
|
|
||||||
@@ -282,6 +339,10 @@ async function interestHighValueSilent(portId: string): Promise<AlertCandidate[]
|
|||||||
eq(interests.portId, portId),
|
eq(interests.portId, portId),
|
||||||
eq(interests.leadCategory, 'hot_lead'),
|
eq(interests.leadCategory, 'hot_lead'),
|
||||||
isNull(interests.archivedAt),
|
isNull(interests.archivedAt),
|
||||||
|
// Don't flood from imported-but-never-touched hot leads (their
|
||||||
|
// dateLastContact is back-dated to a legacy date). Once a user works one
|
||||||
|
// in-system, it becomes eligible again.
|
||||||
|
sql`( NOT ${isImportedSql} OR ${hasFollowupSql} )`,
|
||||||
or(
|
or(
|
||||||
lt(interests.dateLastContact, cutoff),
|
lt(interests.dateLastContact, cutoff),
|
||||||
and(isNull(interests.dateLastContact), lt(interests.updatedAt, cutoff)),
|
and(isNull(interests.dateLastContact), lt(interests.updatedAt, cutoff)),
|
||||||
@@ -335,6 +396,7 @@ async function eoiUnsignedLong(portId: string): Promise<AlertCandidate[]> {
|
|||||||
export const RULE_REGISTRY: Record<AlertRuleId, RuleEvaluator> = {
|
export const RULE_REGISTRY: Record<AlertRuleId, RuleEvaluator> = {
|
||||||
'reservation.no_agreement': reservationNoAgreement,
|
'reservation.no_agreement': reservationNoAgreement,
|
||||||
'interest.stale': interestStale,
|
'interest.stale': interestStale,
|
||||||
|
'interest.no_activity': interestNoActivity,
|
||||||
'document.signer_overdue': documentSignerOverdue,
|
'document.signer_overdue': documentSignerOverdue,
|
||||||
'berth.under_offer_stalled': berthUnderOfferStalled,
|
'berth.under_offer_stalled': berthUnderOfferStalled,
|
||||||
'expense.duplicate': expenseDuplicate,
|
'expense.duplicate': expenseDuplicate,
|
||||||
|
|||||||
@@ -120,6 +120,42 @@ export async function dismissAlert(alertId: string, portId: string, userId: stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-dismiss every open (non-dismissed, non-resolved) alert for a port,
|
||||||
|
* optionally narrowed to a single rule and/or severity. Returns the count
|
||||||
|
* dismissed. Port-scoped so it can never touch another tenant's alerts.
|
||||||
|
*/
|
||||||
|
export async function dismissAllForPort(
|
||||||
|
portId: string,
|
||||||
|
userId: string,
|
||||||
|
filter: { ruleId?: AlertRuleId; severity?: AlertSeverity } = {},
|
||||||
|
): Promise<number> {
|
||||||
|
const conds = [eq(alerts.portId, portId), isNull(alerts.dismissedAt), isNull(alerts.resolvedAt)];
|
||||||
|
if (filter.ruleId) conds.push(eq(alerts.ruleId, filter.ruleId));
|
||||||
|
if (filter.severity) conds.push(eq(alerts.severity, filter.severity));
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.update(alerts)
|
||||||
|
.set({ dismissedAt: sql`now()`, dismissedBy: userId })
|
||||||
|
.where(and(...conds))
|
||||||
|
.returning({ id: alerts.id });
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
emitToRoom(`port:${portId}`, 'alert:dismissed', { alertId: r.id, portId });
|
||||||
|
}
|
||||||
|
if (rows.length > 0) {
|
||||||
|
void createAuditLog({
|
||||||
|
portId,
|
||||||
|
userId,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'alert',
|
||||||
|
entityId: portId, // port-wide bulk action — no single alert subject
|
||||||
|
metadata: { kind: 'dismiss_all', count: rows.length, filter },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
export async function acknowledgeAlert(
|
export async function acknowledgeAlert(
|
||||||
alertId: string,
|
alertId: string,
|
||||||
portId: string,
|
portId: string,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user