fix(uat): batch — timeline overshoot, name-sync, reset-password, dashboard cleanup, queue/seed hygiene + alpha UAT findings doc
UAT findings landed across the last few Playwright + React Grab passes; single grouped commit so the index doesn't fragment into 30 one-liners. User & auth: - `user-settings`: name now updates the avatar + topbar menu after save (was reading stale session). - `me/password-reset`: 3 bugs (token validation, error response shape, redirect chain). - Admin user permission-overrides route honours the same envelope as the rest of the admin surface. Dashboard: - Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card` (replaced by the customisable widget grid). - Strip `revenue_breakdown` from analytics route + use-analytics + service + integration test so nothing renders an empty card. - Activity log timeline overshoot fix (`interest-timeline` + `entity-activity-feed`). - Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile. - `dev-mode-banner`: derive dismissed state synchronously instead of via an effect (set-state-in-effect lint rule). Forms & lists (assorted polish): - client / company / yacht / interest / reminder forms — validation + empty-state copy + tab transitions. - companies/yachts list tweaks; berth recommender panel; qualification checklist; supplemental info request button. Infra & misc: - Queue workers (ai / email / notifications) — log shape + per-job timeout consistency. - Auth / brochures / users schema small adjustments; seeds reflect permissions matrix changes. - Scan shell + scanner manifest + AI admin page small fixes. - `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react` (recommended config from echarts-for-react inside Next). Docs: - `docs/superpowers/audits/alpha-uat-master.md` — single rolling cross-cutting UAT findings doc (per CLAUDE.md convention). - `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log normalization (§J). - 2026-05-18 audit log updated with this batch. - `CLAUDE.md` — small manual UAT scaffold notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,35 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
import { Bot, FileText, Brain, ExternalLink } from 'lucide-react';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
|
||||
|
||||
interface FeatureLink {
|
||||
href: string;
|
||||
icon: typeof Bot;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const FEATURE_LINKS: FeatureLink[] = [
|
||||
{
|
||||
href: '../berth-pdf-parser',
|
||||
icon: FileText,
|
||||
title: 'Berth PDF parser',
|
||||
description:
|
||||
'Three-tier AcroForm → OCR → AI pipeline. The AI pass costs tokens; reps invoke it manually when OCR confidence is low.',
|
||||
},
|
||||
{
|
||||
href: '../recommender',
|
||||
icon: Brain,
|
||||
title: 'Berth recommender',
|
||||
description:
|
||||
'Rule-based today; future versions will optionally use embeddings for soft preference matching. AI use is gated by the master switch above.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AiAdminPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -65,34 +40,6 @@ export default function AiAdminPage() {
|
||||
<OcrSettingsForm embedded />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" /> Per-feature settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Feature-specific tuning lives on each feature's admin page. They all read the
|
||||
master switch + provider credentials configured above.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURE_LINKS.map((f) => (
|
||||
<Link
|
||||
key={f.href}
|
||||
href={f.href as never}
|
||||
className="rounded-md border bg-card p-3 hover:border-primary transition-colors block"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<f.icon className="h-4 w-4 text-muted-foreground" />
|
||||
{f.title}
|
||||
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{f.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ports } from '@/lib/db/schema/ports';
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ portSlug: string }> }) {
|
||||
const { portSlug } = await params;
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
||||
const portName = port?.name ?? 'Port Nimara';
|
||||
const portName = port?.name ?? 'CRM';
|
||||
|
||||
const manifest = {
|
||||
name: `${portName} - Scanner`,
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { ScanShell } from '@/components/scan/scan-shell';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Scan receipt - Port Nimara',
|
||||
title: 'Scan receipt',
|
||||
};
|
||||
|
||||
export default function ScanPage() {
|
||||
return <ScanShell />;
|
||||
export default async function ScanPage({ params }: { params: Promise<{ portSlug: string }> }) {
|
||||
const { portSlug } = await params;
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
||||
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
|
||||
return <ScanShell logoUrl={branding?.logoUrl ?? null} portName={port?.name ?? null} />;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/lib/email/templates/residential-inquiry';
|
||||
import { resolveSubject } from '@/lib/email/resolve-subject';
|
||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
|
||||
import { env } from '@/lib/env';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||
@@ -142,12 +143,22 @@ async function sendResidentialNotifications(args: {
|
||||
}): Promise<void> {
|
||||
const { portId, data, crmDeepLink } = args;
|
||||
|
||||
const branding = await getBrandingShell(portId);
|
||||
const [branding, portBrand, emailCfg] = await Promise.all([
|
||||
getBrandingShell(portId),
|
||||
getPortBrandingConfig(portId).catch(() => null),
|
||||
getPortEmailConfig(portId).catch(() => null),
|
||||
]);
|
||||
// Use the port's configured From address (or branding app name) for
|
||||
// the "contact us" line on the confirmation email so other ports don't
|
||||
// direct replies to sales@portnimara.com.
|
||||
const contactEmail = emailCfg?.fromAddress ?? '';
|
||||
const portName = portBrand?.appName ?? 'our team';
|
||||
|
||||
// Client confirmation
|
||||
const confirmation = await residentialClientConfirmation(
|
||||
{
|
||||
firstName: data.firstName,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
contactEmail,
|
||||
},
|
||||
{ branding },
|
||||
);
|
||||
@@ -155,7 +166,7 @@ async function sendResidentialNotifications(args: {
|
||||
key: 'residential_inquiry_client_confirmation',
|
||||
portId,
|
||||
fallback: confirmation.subject,
|
||||
tokens: { portName: 'Port Nimara', recipientName: data.firstName },
|
||||
tokens: { portName, recipientName: data.firstName },
|
||||
});
|
||||
await sendEmail(data.email, confirmationSubject, confirmation.html, undefined, undefined, portId);
|
||||
|
||||
@@ -204,7 +215,7 @@ async function sendResidentialNotifications(args: {
|
||||
portId,
|
||||
fallback: alert.subject,
|
||||
tokens: {
|
||||
portName: 'Port Nimara',
|
||||
portName,
|
||||
clientName: `${data.firstName} ${data.lastName}`.trim(),
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
|
||||
@@ -46,7 +46,7 @@ const ALLOWED_RESOURCE_ACTIONS: Record<string, Set<string>> = {
|
||||
'generate_eoi',
|
||||
'export',
|
||||
]),
|
||||
berths: new Set(['view', 'edit', 'import', 'manage_waiting_list']),
|
||||
berths: new Set(['view', 'edit', 'import', 'manage_waiting_list', 'update_prices']),
|
||||
documents: new Set([
|
||||
'view',
|
||||
'create',
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getLeadSourceAttribution,
|
||||
getOccupancyTimeline,
|
||||
getPipelineFunnel,
|
||||
getRevenueBreakdown,
|
||||
type DateRange,
|
||||
type MetricBase,
|
||||
type PresetDateRange,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
|
||||
pipeline_funnel: getPipelineFunnel,
|
||||
occupancy_timeline: getOccupancyTimeline,
|
||||
revenue_breakdown: getRevenueBreakdown,
|
||||
lead_source_attribution: getLeadSourceAttribution,
|
||||
};
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ export const POST = withAuth(
|
||||
|
||||
await sendSigningInvitation({
|
||||
portId: ctx.portId,
|
||||
portName: port?.name ?? 'Port Nimara',
|
||||
portName: port?.name ?? 'the marina',
|
||||
recipient: { name: target.signerName, email: target.signerEmail },
|
||||
documensoSigningUrl: target.signingUrl,
|
||||
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
||||
|
||||
@@ -20,7 +20,9 @@ export const POST = withAuth(async (_req, ctx) => {
|
||||
await auth.api.requestPasswordReset({
|
||||
body: {
|
||||
email: ctx.user.email,
|
||||
redirectTo: '/reset-password',
|
||||
// /set-password is the form that actually consumes the token.
|
||||
// /reset-password is the "request a link" entry point.
|
||||
redirectTo: '/set-password',
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -32,7 +32,13 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
|
||||
berths: {
|
||||
view: false,
|
||||
edit: false,
|
||||
import: false,
|
||||
manage_waiting_list: false,
|
||||
update_prices: false,
|
||||
},
|
||||
documents: {
|
||||
view: false,
|
||||
create: false,
|
||||
|
||||
@@ -104,7 +104,7 @@ const KNOWN_SETTINGS: Array<{
|
||||
description:
|
||||
'Reply-to email shown in client confirmation emails when a new interest is registered',
|
||||
type: 'string',
|
||||
defaultValue: 'sales@portnimara.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'inquiry_notification_recipients',
|
||||
@@ -129,8 +129,8 @@ const KNOWN_SETTINGS: Array<{
|
||||
'Internal staff who countersign every EOI. JSON object with `developer` (signs after the client) and `approver` (final approval). Both fields take `{ name, email }`.',
|
||||
type: 'json',
|
||||
defaultValue: {
|
||||
developer: { name: 'David Mizrahi', email: 'dm@portnimara.com' },
|
||||
approver: { name: 'Abbie May', email: 'sales@portnimara.com' },
|
||||
developer: { name: '', email: '' },
|
||||
approver: { name: '', email: '' },
|
||||
},
|
||||
},
|
||||
// ─── Berth recommender (src/lib/services/berth-recommender.service.ts) ──────
|
||||
|
||||
@@ -67,7 +67,7 @@ const PERMISSION_LEAVES: Record<string, string[]> = {
|
||||
'generate_eoi',
|
||||
'export',
|
||||
],
|
||||
berths: ['view', 'edit', 'import', 'manage_waiting_list'],
|
||||
berths: ['view', 'edit', 'import', 'manage_waiting_list', 'update_prices'],
|
||||
documents: [
|
||||
'view',
|
||||
'create',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm, useFieldArray } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -22,6 +22,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
||||
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
||||
import { PhoneInput } from '@/components/shared/phone-input';
|
||||
import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel';
|
||||
@@ -107,6 +108,18 @@ export function ClientForm({
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
|
||||
const tagIds = watch('tagIds') ?? [];
|
||||
|
||||
// Primary-address fields. Live outside RHF because the API splits
|
||||
// client creation (`POST /api/v1/clients`) from address creation
|
||||
// (`POST /api/v1/clients/{id}/addresses`) — the address gets chained
|
||||
// after the client POST returns the new id. Edit mode uses the
|
||||
// dedicated Addresses tab; the form here is create-only.
|
||||
const [addressOpen, setAddressOpen] = useState(false);
|
||||
const [addrStreet, setAddrStreet] = useState('');
|
||||
const [addrCity, setAddrCity] = useState('');
|
||||
const [addrSubdivisionIso, setAddrSubdivisionIso] = useState<string | null>(null);
|
||||
const [addrPostal, setAddrPostal] = useState('');
|
||||
const [addrCountryIso, setAddrCountryIso] = useState<string | null>(null);
|
||||
|
||||
// When the rep picks a country and no timezone is set yet, pre-fill the
|
||||
// timezone with the country's primary IANA zone. Skips when the user has
|
||||
// already chosen a zone explicitly so we never clobber a deliberate pick.
|
||||
@@ -169,10 +182,21 @@ export function ClientForm({
|
||||
reset({
|
||||
fullName: prefill?.fullName ?? '',
|
||||
contacts,
|
||||
source: prefill?.source,
|
||||
// Default a manually-created client to `source='manual'` so reps
|
||||
// don't have to remember to pick it. The inquiry-inbox triage
|
||||
// flow overrides this via `prefill.source='website'`; the global
|
||||
// command-search quick-create has no `prefill.source` and
|
||||
// therefore correctly lands on 'manual'.
|
||||
source: prefill?.source ?? 'manual',
|
||||
sourceInquiryId: prefill?.sourceInquiryId,
|
||||
tagIds: [],
|
||||
});
|
||||
setAddressOpen(false);
|
||||
setAddrStreet('');
|
||||
setAddrCity('');
|
||||
setAddrSubdivisionIso(null);
|
||||
setAddrPostal('');
|
||||
setAddrCountryIso(null);
|
||||
}
|
||||
}, [client, open, reset, prefill]);
|
||||
|
||||
@@ -224,7 +248,39 @@ export function ClientForm({
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await apiFetch('/api/v1/clients', { method: 'POST', body: payload });
|
||||
const res = await apiFetch<{ data: { id: string } }>('/api/v1/clients', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
// Chain the address POST when any field is filled. Address errors
|
||||
// don't unwind the client create — surface a toast warning and
|
||||
// leave the client in place so the rep can finish in the
|
||||
// Addresses tab.
|
||||
const hasAddress =
|
||||
addrStreet.trim() ||
|
||||
addrCity.trim() ||
|
||||
addrPostal.trim() ||
|
||||
addrSubdivisionIso ||
|
||||
addrCountryIso;
|
||||
if (hasAddress) {
|
||||
try {
|
||||
await apiFetch(`/api/v1/clients/${res.data.id}/addresses`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
streetAddress: addrStreet.trim() || null,
|
||||
city: addrCity.trim() || null,
|
||||
subdivisionIso: addrSubdivisionIso ?? undefined,
|
||||
postalCode: addrPostal.trim() || null,
|
||||
countryIso: addrCountryIso ?? undefined,
|
||||
isPrimary: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
toast.error(
|
||||
'Client created but the address could not be saved. Add it from the Addresses tab.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -533,6 +589,95 @@ export function ClientForm({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Primary Address — create-only. Editing happens in the
|
||||
client detail page's Addresses tab. */}
|
||||
{!isEdit ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Primary Address
|
||||
</h3>
|
||||
{!addressOpen ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAddressOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
Add address
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddressOpen(false);
|
||||
setAddrStreet('');
|
||||
setAddrCity('');
|
||||
setAddrSubdivisionIso(null);
|
||||
setAddrPostal('');
|
||||
setAddrCountryIso(null);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{addressOpen ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2 space-y-1">
|
||||
<Label>Street address</Label>
|
||||
<Input
|
||||
value={addrStreet}
|
||||
onChange={(e) => setAddrStreet(e.target.value)}
|
||||
placeholder="123 Marina Way, Suite 4"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>City</Label>
|
||||
<Input
|
||||
value={addrCity}
|
||||
onChange={(e) => setAddrCity(e.target.value)}
|
||||
placeholder="Anguilla"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Postal code</Label>
|
||||
<Input
|
||||
value={addrPostal}
|
||||
onChange={(e) => setAddrPostal(e.target.value)}
|
||||
placeholder="AI-2640"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Country</Label>
|
||||
<CountryCombobox
|
||||
value={addrCountryIso}
|
||||
onChange={(iso) => {
|
||||
setAddrCountryIso(iso ?? null);
|
||||
// Clear region if country changes — keeps the
|
||||
// subdivision picker consistent with its country.
|
||||
setAddrSubdivisionIso(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Region / State</Label>
|
||||
<SubdivisionCombobox
|
||||
country={addrCountryIso as CountryCode | null}
|
||||
value={addrSubdivisionIso}
|
||||
onChange={(iso) => setAddrSubdivisionIso(iso)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isEdit ? <Separator /> : null}
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
|
||||
@@ -75,9 +75,17 @@ interface CompanyFormProps {
|
||||
billingEmail: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
/**
|
||||
* Optional initial values for the create flow — used by the global
|
||||
* command-search quick-create ("New company 'matthew'" → lands on
|
||||
* `/companies?create=1&prefill_name=matthew`). Ignored in edit mode.
|
||||
*/
|
||||
prefill?: {
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const isEdit = !!company;
|
||||
@@ -141,10 +149,10 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
tagIds: [],
|
||||
});
|
||||
} else if (!company && open) {
|
||||
reset({ name: '', status: 'active', tagIds: [] });
|
||||
reset({ name: prefill?.name ?? '', status: 'active', tagIds: [] });
|
||||
}
|
||||
setFormError(null);
|
||||
}, [company, open, reset]);
|
||||
}, [company, open, reset, prefill]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: CreateCompanyInput) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { Plus, Archive, Tag as TagIcon, TagsIcon } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
@@ -49,6 +49,16 @@ export function CompanyList() {
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
useCreateFromUrl(() => setCreateOpen(true));
|
||||
|
||||
// Quick-create from the global command-search lands here with
|
||||
// `?create=1&prefill_name=…`. Hydrate the company-form's name field
|
||||
// so the rep doesn't retype.
|
||||
const searchParams = useSearchParams();
|
||||
const createPrefill = useMemo(() => {
|
||||
const name = searchParams?.get('prefill_name');
|
||||
return name ? { name } : undefined;
|
||||
}, [searchParams]);
|
||||
|
||||
const [editCompany, setEditCompany] = useState<CompanyRow | null>(null);
|
||||
const [archiveCompany, setArchiveCompany] = useState<CompanyRow | null>(null);
|
||||
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
|
||||
@@ -274,7 +284,7 @@ export function CompanyList() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CompanyForm open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<CompanyForm open={createOpen} onOpenChange={setCreateOpen} prefill={createPrefill} />
|
||||
|
||||
{editCompany && (
|
||||
<CompanyForm
|
||||
|
||||
@@ -35,10 +35,11 @@ export function ActiveDealsTile() {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* `pt-5 pb-5` is explicit because shadcn's default CardContent ships
|
||||
with `pt-0` (it assumes a CardHeader sits above). Without these
|
||||
overrides the tile content snaps to the top edge of the card. */}
|
||||
<CardContent className="flex items-center gap-3 pt-5 pb-5">
|
||||
{/* shadcn's default CardContent ships with `pt-0 sm:pt-0` (it assumes a
|
||||
CardHeader sits above). The `sm:` variants are required — without
|
||||
them `sm:pt-0` wins at the sm breakpoint and the content snaps to
|
||||
the top edge. */}
|
||||
<CardContent className="flex items-center gap-3 pt-5 pb-5 sm:pt-5 sm:pb-5">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-accent text-foreground">
|
||||
<TrendingUp className="size-5" aria-hidden />
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Berth Heat widget — ranked table of berths by active interest count.
|
||||
* Investor-friendly "where is the demand pressure?" surface. Renders
|
||||
* a sortable table that exports cleanly to PDF/CSV. A future heatmap
|
||||
* visualization can sit beside this table reading the same data.
|
||||
* Berth-demand widget — ranks berths by active interest count, with a
|
||||
* horizontal bar per row encoding magnitude relative to the leader.
|
||||
* Matches the standard CardHeader / CardContent layout of its dashboard
|
||||
* siblings; the bars (not chrome) do the visual work.
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ArrowRight, TrendingUp } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { StatusPill } from '@/components/ui/status-pill';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface HeatRow {
|
||||
berthId: string;
|
||||
@@ -27,13 +28,16 @@ interface HeatResponse {
|
||||
data: { rows: HeatRow[] };
|
||||
}
|
||||
|
||||
// Render the raw status — StatusPill recognizes 'available' /
|
||||
// 'under_offer' / 'sold' as canonical tokens and applies the right tone.
|
||||
function statusToken(s: string): 'available' | 'under_offer' | 'sold' | 'pending' {
|
||||
if (s === 'available' || s === 'under_offer' || s === 'sold') return s;
|
||||
return 'pending';
|
||||
function formatStatus(s: string): string {
|
||||
return s.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
const BAR_COLOR_BY_STATUS: Record<string, string> = {
|
||||
available: 'bg-success/70',
|
||||
under_offer: 'bg-warning/80',
|
||||
sold: 'bg-error/60',
|
||||
};
|
||||
|
||||
export function BerthHeatWidget() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
@@ -43,52 +47,87 @@ export function BerthHeatWidget() {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const ranked = (data?.data?.rows ?? []).filter((r) => r.activeInterestCount > 0);
|
||||
const visible = ranked.slice(0, 6);
|
||||
const max = visible[0]?.activeInterestCount ?? 1;
|
||||
const totalActive = ranked.reduce((sum, r) => sum + r.activeInterestCount, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Berth heat</CardTitle>
|
||||
<CardDescription>Top 15 berths by active interest count.</CardDescription>
|
||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||
<TrendingUp className="size-4 text-brand-600" aria-hidden />
|
||||
Berth demand
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{ranked.length > 0
|
||||
? `${totalActive} active interest${totalActive === 1 ? '' : 's'} across ${ranked.length} ${ranked.length === 1 ? 'berth' : 'berths'}.`
|
||||
: 'Berths ranked by active interest count.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden /> Loading…
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.data.rows.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No active interests yet.</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
No active demand yet. Berths will appear here as interests land.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-muted-foreground">
|
||||
<th className="py-1.5 text-left font-medium">Berth</th>
|
||||
<th className="py-1.5 text-left font-medium">Dock</th>
|
||||
<th className="py-1.5 text-left font-medium">Status</th>
|
||||
<th className="py-1.5 text-right font-medium">Interests</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.data.rows.map((r) => (
|
||||
<tr key={r.berthId} className="border-b last:border-b-0">
|
||||
<td className="py-1.5">
|
||||
<>
|
||||
<ul className="space-y-1">
|
||||
{visible.map((r) => {
|
||||
const widthPct = Math.max(6, Math.round((r.activeInterestCount / max) * 100));
|
||||
return (
|
||||
<li key={r.berthId}>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/berths/${r.berthId}` as any}
|
||||
className="font-medium hover:underline"
|
||||
className="-mx-2 grid grid-cols-[3.25rem_1fr_2rem] items-center gap-3 rounded-md px-2 py-2 hover:bg-accent/60"
|
||||
>
|
||||
{r.mooringNumber}
|
||||
<span className="truncate font-mono text-sm font-medium text-foreground">
|
||||
{r.mooringNumber}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
BAR_COLOR_BY_STATUS[r.status] ?? 'bg-brand-400',
|
||||
)}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{r.area ?? 'Unassigned'} ·{' '}
|
||||
<span className="capitalize">{formatStatus(r.status)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-right text-sm font-semibold text-foreground tabular-nums">
|
||||
{r.activeInterestCount}
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-1.5 text-muted-foreground">{r.area ?? '—'}</td>
|
||||
<td className="py-1.5">
|
||||
<StatusPill status={statusToken(r.status)}>
|
||||
{r.status.replace(/_/g, ' ')}
|
||||
</StatusPill>
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-semibold">{r.activeInterestCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{ranked.length > visible.length ? (
|
||||
<div className="mt-3 border-t pt-2 text-right">
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/berths?sort=activeInterestCount&order=desc` as any}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View all by demand
|
||||
<ArrowRight className="size-3" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,57 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DollarSign } from 'lucide-react';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PIPELINE_STAGES, STAGE_WEIGHTS, stageLabel } from '@/lib/constants';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface KpiResponse {
|
||||
pipelineValue: number;
|
||||
pipelineValueCurrency: string;
|
||||
activeInterests: number;
|
||||
}
|
||||
|
||||
interface StageRow {
|
||||
stage: string;
|
||||
count: number;
|
||||
grossValue: number;
|
||||
weightedValue: number;
|
||||
weight: number;
|
||||
dealsMissingPrice: number;
|
||||
}
|
||||
|
||||
interface ForecastResponse {
|
||||
totalGrossValue: number;
|
||||
totalWeightedValue: number;
|
||||
stageBreakdown: StageRow[];
|
||||
weightsSource: 'db' | 'default';
|
||||
}
|
||||
|
||||
// Same brand-coloured family the pipeline-funnel chart uses so the two
|
||||
// surfaces feel anchored to the same palette.
|
||||
const STAGE_BAR_CLASS: Record<string, string> = {
|
||||
enquiry: 'bg-slate-300',
|
||||
qualified: 'bg-brand-200',
|
||||
nurturing: 'bg-brand-300',
|
||||
eoi: 'bg-brand-400',
|
||||
reservation: 'bg-amber-400',
|
||||
deposit_paid: 'bg-orange-400',
|
||||
contract: 'bg-success/70',
|
||||
};
|
||||
|
||||
/**
|
||||
* Total pipeline value for active interests, converted to the port's
|
||||
* default currency at display time. Sourced from the same KPIs endpoint
|
||||
* as the active-deals tile so the two share a cache entry and render in
|
||||
* lockstep.
|
||||
* Headline pipeline value plus a per-stage breakdown showing gross
|
||||
* value, deal count, and the weighted forecast (gross × stage close-
|
||||
* probability). Replaces the single-number KPI: leadership can now see
|
||||
* how much of the headline number is near-close vs speculative.
|
||||
*
|
||||
* Pulls from two endpoints: `/kpis` for the gross headline + currency
|
||||
* and `/forecast` for the weighted breakdown. Both share cache entries
|
||||
* with other widgets so this is mostly free.
|
||||
*/
|
||||
export function PipelineValueTile() {
|
||||
const { data, isLoading } = useQuery<KpiResponse>({
|
||||
const kpis = useQuery<KpiResponse>({
|
||||
queryKey: ['dashboard', 'kpis'],
|
||||
queryFn: () => apiFetch<KpiResponse>('/api/v1/dashboard/kpis'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const forecast = useQuery<ForecastResponse>({
|
||||
queryKey: ['dashboard', 'forecast'],
|
||||
queryFn: () => apiFetch<ForecastResponse>('/api/v1/dashboard/forecast'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const isLoading = kpis.isLoading || forecast.isLoading;
|
||||
const currency = kpis.data?.pipelineValueCurrency ?? 'USD';
|
||||
const grossTotal = kpis.data?.pipelineValue ?? 0;
|
||||
const weightedTotal = forecast.data?.totalWeightedValue ?? 0;
|
||||
const activeDeals = kpis.data?.activeInterests ?? 0;
|
||||
const activeStages = (forecast.data?.stageBreakdown ?? []).filter((s) => s.count > 0);
|
||||
const stageMax = activeStages.reduce((m, s) => Math.max(m, s.grossValue), 0) || 1;
|
||||
|
||||
const fmt = (v: number) => formatCurrency(v, currency, { maxFractionDigits: 0 });
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* `pt-5 pb-5` is explicit because shadcn's default CardContent ships
|
||||
with `pt-0` (it assumes a CardHeader sits above). Without these
|
||||
overrides the tile content snaps to the top edge of the card. */}
|
||||
<CardContent className="flex items-center gap-3 pt-5 pb-5">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-accent text-foreground">
|
||||
<DollarSign className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Pipeline value
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<Skeleton className="mt-1 h-7 w-24" aria-hidden />
|
||||
) : (
|
||||
<p
|
||||
className="truncate text-2xl font-bold leading-tight text-foreground"
|
||||
title={formatCurrency(data?.pipelineValue ?? 0, data?.pipelineValueCurrency ?? 'USD')}
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Pipeline value</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<span>
|
||||
{activeDeals > 0
|
||||
? `${activeDeals} active deal${activeDeals === 1 ? '' : 's'} · weighted by stage close-probability`
|
||||
: 'Gross berth value across active deals, with weighted forecast.'}
|
||||
</span>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
type="button"
|
||||
aria-label="How does the weighted forecast work?"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
|
||||
>
|
||||
{formatCurrency(data?.pipelineValue ?? 0, data?.pipelineValueCurrency ?? 'USD', {
|
||||
maxFractionDigits: 0,
|
||||
})}
|
||||
<Info className="size-3.5" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
|
||||
<p className="font-semibold text-foreground">How the weighted forecast works</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Each pipeline stage has a close-probability — how likely a deal at that stage is to
|
||||
actually close. Multiplying the berth price by the stage weight gives an{' '}
|
||||
<strong>expected</strong> value for that deal. Summing across every active deal
|
||||
yields the weighted forecast — a defensible “what will likely land”
|
||||
number, vs the gross which assumes every deal closes at full value.
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 rounded-md bg-muted/50 p-2.5 text-[11px]">
|
||||
{PIPELINE_STAGES.map((s) => {
|
||||
const dbWeight = forecast.data?.stageBreakdown.find((r) => r.stage === s)?.weight;
|
||||
const weight = dbWeight ?? STAGE_WEIGHTS[s];
|
||||
return (
|
||||
<div key={s} className="contents">
|
||||
<span className="text-muted-foreground">{stageLabel(s)}</span>
|
||||
<span className="text-right font-medium tabular-nums text-foreground">
|
||||
{Math.round(weight * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">
|
||||
{forecast.data?.weightsSource === 'db'
|
||||
? 'Using per-port weights (admins tune these in Settings → Pipeline).'
|
||||
: 'Using system defaults. Admins can override per port in Settings → Pipeline.'}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* ── Headline numbers ─────────────────────────────────────── */}
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Gross
|
||||
</p>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<Skeleton className="mt-1 h-7 w-28" aria-hidden />
|
||||
) : (
|
||||
<p
|
||||
className="truncate text-2xl font-bold leading-tight text-foreground"
|
||||
title={formatCurrency(grossTotal, currency)}
|
||||
>
|
||||
{fmt(grossTotal)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Weighted forecast
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<Skeleton className="ml-auto mt-1 h-6 w-24" aria-hidden />
|
||||
) : (
|
||||
<p
|
||||
className="text-lg font-semibold leading-tight text-foreground tabular-nums"
|
||||
title={formatCurrency(weightedTotal, currency)}
|
||||
>
|
||||
{fmt(weightedTotal)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Per-stage breakdown ─────────────────────────────────── */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-7 w-full" aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
) : activeStages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No active deals with linked berths yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{activeStages.map((s) => {
|
||||
const widthPct = Math.max(6, Math.round((s.grossValue / stageMax) * 100));
|
||||
return (
|
||||
<li
|
||||
key={s.stage}
|
||||
className="grid grid-cols-[1fr_auto] items-center gap-x-3 gap-y-0.5 py-1"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{stageLabel(s.stage)}
|
||||
</p>
|
||||
<div className="mt-1 flex h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
STAGE_BAR_CLASS[s.stage] ?? 'bg-brand-400',
|
||||
)}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-foreground tabular-nums">
|
||||
{fmt(s.grossValue)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{s.count} {s.count === 1 ? 'deal' : 'deals'} · {Math.round(s.weight * 100)}%
|
||||
</p>
|
||||
{s.dealsMissingPrice > 0 ? (
|
||||
<p
|
||||
className="mt-0.5 inline-flex items-center gap-1 text-[10px] font-medium text-warning"
|
||||
title={`${s.dealsMissingPrice} of ${s.count} ${s.count === 1 ? 'deal has' : 'deals have'} a berth with no price set — gross is undercounted here.`}
|
||||
>
|
||||
<AlertTriangle className="size-3" aria-hidden />
|
||||
{s.dealsMissingPrice === s.count
|
||||
? 'berth price missing'
|
||||
: `${s.dealsMissingPrice} of ${s.count} missing price`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{forecast.data?.weightsSource === 'default' ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Using default stage weights. Tune them in Settings → Pipeline.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useRevenue } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
import { rangeToSlug } from '@/lib/analytics/range';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
draft: 'Draft',
|
||||
sent: 'Sent',
|
||||
paid: 'Paid',
|
||||
overdue: 'Overdue',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
export function RevenueBreakdownChart({ range }: Props) {
|
||||
const { data, isLoading } = useRevenue(range);
|
||||
const bars = data?.bars ?? [];
|
||||
|
||||
function toCsv(): string | null {
|
||||
if (!bars.length) return null;
|
||||
const header = 'status,currency,amount';
|
||||
const rows = bars.map((b) => `${b.status},${b.currency},${b.amount}`);
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
const chartData = bars.map((b) => ({
|
||||
label: `${STATUS_LABELS[b.status] ?? b.status} (${b.currency})`,
|
||||
amount: b.amount,
|
||||
currency: b.currency,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="Revenue Breakdown"
|
||||
description="Invoice totals grouped by status and currency"
|
||||
exportFilename={`revenue-breakdown-${rangeToSlug(range)}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : !bars.length ? (
|
||||
<EmptyState
|
||||
title="No invoices in range"
|
||||
description="Issued, paid, and overdue totals appear here once you create invoices."
|
||||
/>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
angle={-30}
|
||||
textAnchor="end"
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value, _name, item) => {
|
||||
const c = (item?.payload as { currency?: string } | undefined)?.currency ?? 'USD';
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
return [formatCurrency(num, c), 'Amount'];
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="amount" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
MetricBase,
|
||||
OccupancyTimelineData,
|
||||
PipelineFunnelData,
|
||||
RevenueBreakdownData,
|
||||
} from '@/lib/services/analytics.service';
|
||||
|
||||
interface MetricResponse<T> {
|
||||
@@ -51,7 +50,5 @@ export const useFunnel = (range: DateRange) =>
|
||||
useAnalyticsMetric<PipelineFunnelData>('pipeline_funnel', range);
|
||||
export const useOccupancy = (range: DateRange) =>
|
||||
useAnalyticsMetric<OccupancyTimelineData>('occupancy_timeline', range);
|
||||
export const useRevenue = (range: DateRange) =>
|
||||
useAnalyticsMetric<RevenueBreakdownData>('revenue_breakdown', range);
|
||||
export const useLeadSource = (range: DateRange) =>
|
||||
useAnalyticsMetric<LeadSourceAttributionData>('lead_source_attribution', range);
|
||||
|
||||
@@ -48,10 +48,6 @@ const PipelineFunnelChart = dynamic(
|
||||
() => import('./pipeline-funnel-chart').then((m) => ({ default: m.PipelineFunnelChart })),
|
||||
{ loading: ChartFallback, ssr: false },
|
||||
);
|
||||
const RevenueBreakdownChart = dynamic(
|
||||
() => import('./revenue-breakdown-chart').then((m) => ({ default: m.RevenueBreakdownChart })),
|
||||
{ loading: ChartFallback, ssr: false },
|
||||
);
|
||||
const SourceConversionChart = dynamic(
|
||||
() => import('./source-conversion-chart').then((m) => ({ default: m.SourceConversionChart })),
|
||||
{ loading: ChartFallback, ssr: false },
|
||||
@@ -123,12 +119,13 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
{
|
||||
id: 'kpi_pipeline_value',
|
||||
label: 'Pipeline Value',
|
||||
description: 'Total berth value of active deals, converted to the port default currency.',
|
||||
description:
|
||||
'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.',
|
||||
render: () => <PipelineValueTile />,
|
||||
group: 'rail',
|
||||
// Flipped on by default 2026-05-14 — the dashboard wave prioritized
|
||||
// investor-facing tiles, and this is the headline number leadership
|
||||
// looks at first.
|
||||
// Lives in the chart grid (not the narrow rail) so the per-stage
|
||||
// breakdown rows have room to breathe alongside the headline numbers,
|
||||
// and the rail stays reserved for reminders / alerts / glance tiles.
|
||||
group: 'chart',
|
||||
defaultVisible: true,
|
||||
},
|
||||
|
||||
@@ -149,14 +146,6 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
group: 'chart',
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: 'revenue_breakdown',
|
||||
label: 'Revenue Breakdown',
|
||||
description: 'Invoice totals grouped by status and currency.',
|
||||
render: (range) => <RevenueBreakdownChart range={range} />,
|
||||
group: 'chart',
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: 'lead_source',
|
||||
label: 'Lead Source Attribution',
|
||||
@@ -186,8 +175,9 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
},
|
||||
{
|
||||
id: 'berth_heat',
|
||||
label: 'Berth Heat',
|
||||
description: 'Top 15 berths by active interest count. Investor-friendly demand pressure view.',
|
||||
label: 'Berth Demand',
|
||||
description:
|
||||
'Ranks berths by active interest. Surfaces the leading mooring with its runners-up.',
|
||||
render: () => <BerthHeatWidget />,
|
||||
group: 'chart',
|
||||
defaultVisible: true,
|
||||
@@ -196,7 +186,7 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
id: 'website_analytics',
|
||||
label: 'Website Analytics',
|
||||
description: 'Quick glance at marketing site traffic. Requires Umami.',
|
||||
render: () => <WebsiteGlanceTile />,
|
||||
render: (range) => <WebsiteGlanceTile range={range} />,
|
||||
group: 'rail',
|
||||
defaultVisible: true,
|
||||
selfGates: true,
|
||||
|
||||
@@ -81,6 +81,14 @@ interface BerthRecommenderPanelProps {
|
||||
* Falls back to 'ft' when missing.
|
||||
*/
|
||||
desiredUnit?: 'ft' | 'm' | null;
|
||||
/**
|
||||
* Number of berths already linked to the interest. When ≥ 1 the panel
|
||||
* defaults to collapsed (header-only) so the LinkedBerthsList card above
|
||||
* dominates the rep's attention. They can expand to browse more options
|
||||
* (multi-berth deals, swap recommendations). Zero / undefined keeps the
|
||||
* panel expanded so reps see options immediately.
|
||||
*/
|
||||
linkedBerthCount?: number;
|
||||
}
|
||||
|
||||
const TIER_LABELS: Record<Tier, { label: string; tone: string }> = {
|
||||
@@ -358,6 +366,7 @@ export function BerthRecommenderPanel({
|
||||
desiredWidthFt,
|
||||
desiredDraftFt,
|
||||
desiredUnit,
|
||||
linkedBerthCount,
|
||||
}: BerthRecommenderPanelProps) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
@@ -370,6 +379,11 @@ export function BerthRecommenderPanel({
|
||||
// single pier (e.g. "show me only A-row matches"). Client-side over
|
||||
// the already-fetched result set; no service change required.
|
||||
const [selectedAreas, setSelectedAreas] = useState<string[]>([]);
|
||||
// Collapse state — defaults to collapsed when the deal already has at
|
||||
// least one linked berth (recommender becomes a "browse more options"
|
||||
// tool rather than the primary surface). Reps can manually expand any
|
||||
// time. Header click toggles.
|
||||
const [collapsed, setCollapsed] = useState<boolean>((linkedBerthCount ?? 0) > 0);
|
||||
|
||||
const hasDimensions = desiredLengthFt !== null;
|
||||
|
||||
@@ -380,7 +394,9 @@ export function BerthRecommenderPanel({
|
||||
|
||||
const { data, isFetching, refetch } = useQuery({
|
||||
queryKey,
|
||||
enabled: hasDimensions,
|
||||
// Skip the network call when collapsed — no point fetching options
|
||||
// the rep won't see. Re-fires automatically on expand.
|
||||
enabled: hasDimensions && !collapsed,
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: Recommendation[] }>(`/api/v1/interests/${interestId}/recommend-berths`, {
|
||||
method: 'POST',
|
||||
@@ -441,32 +457,56 @@ export function BerthRecommenderPanel({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!collapsed ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
disabled={!hasDimensions}
|
||||
>
|
||||
<Filter className="mr-1.5 size-3.5" aria-hidden />
|
||||
{filtersOpen ? 'Hide filters' : 'Add filters'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={!hasDimensions || isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('mr-1.5 size-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
disabled={!hasDimensions}
|
||||
variant="ghost"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={`recommender-body-${interestId}`}
|
||||
>
|
||||
<Filter className="mr-1.5 size-3.5" aria-hidden />
|
||||
{filtersOpen ? 'Hide filters' : 'Add filters'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={!hasDimensions || isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('mr-1.5 size-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
{collapsed ? (
|
||||
<>
|
||||
<ChevronDown className="mr-1.5 size-3.5" aria-hidden />
|
||||
Show recommendations
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronUp className="mr-1.5 size-3.5" aria-hidden />
|
||||
Hide
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{filtersOpen && hasDimensions ? (
|
||||
{!collapsed && filtersOpen && hasDimensions ? (
|
||||
<AmenityFilterForm filters={amenityFilters} onChange={setAmenityFilters} />
|
||||
) : null}
|
||||
{hasDimensions && areaChips.length > 1 ? (
|
||||
{!collapsed && hasDimensions && areaChips.length > 1 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Area:</span>
|
||||
{areaChips.map((letter) => {
|
||||
@@ -504,55 +544,57 @@ export function BerthRecommenderPanel({
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!hasDimensions ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Once length, width, and draft are set on this interest, the recommender will surface
|
||||
berths that fit. Edit the desired dimensions on the{' '}
|
||||
<Link href="?tab=overview" className="text-primary underline">
|
||||
Overview tab
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : isFetching && recommendations.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : recommendations.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground space-y-2">
|
||||
<p>
|
||||
{showAll
|
||||
? 'No berths in the port match these dimensions and filters.'
|
||||
: 'No berths fit inside the strict oversize tolerance.'}
|
||||
{collapsed ? null : (
|
||||
<CardContent className="space-y-3" id={`recommender-body-${interestId}`}>
|
||||
{!hasDimensions ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Once length, width, and draft are set on this interest, the recommender will surface
|
||||
berths that fit. Edit the desired dimensions on the{' '}
|
||||
<Link href="?tab=overview" className="text-primary underline">
|
||||
Overview tab
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
{!showAll && (
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setShowAll(true)}>
|
||||
Show oversized matches too
|
||||
) : isFetching && recommendations.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : recommendations.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground space-y-2">
|
||||
<p>
|
||||
{showAll
|
||||
? 'No berths in the port match these dimensions and filters.'
|
||||
: 'No berths fit inside the strict oversize tolerance.'}
|
||||
</p>
|
||||
{!showAll && (
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setShowAll(true)}>
|
||||
Show oversized matches too
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recommendations.map((rec) => (
|
||||
<RecommendationCard
|
||||
key={rec.berthId}
|
||||
rec={rec}
|
||||
portSlug={portSlug}
|
||||
onAdd={setPendingBerth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasDimensions && recommendations.length > 0 ? (
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? 'Show top in-tolerance only' : 'Show oversized matches too'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recommendations.map((rec) => (
|
||||
<RecommendationCard
|
||||
key={rec.berthId}
|
||||
rec={rec}
|
||||
portSlug={portSlug}
|
||||
onAdd={setPendingBerth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasDimensions && recommendations.length > 0 ? (
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? 'Show top in-tolerance only' : 'Show oversized matches too'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{pendingBerth ? (
|
||||
<AddBerthToInterestDialog
|
||||
|
||||
@@ -108,6 +108,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
clientId: '',
|
||||
yachtId: undefined,
|
||||
pipelineStage: 'enquiry',
|
||||
// Default a manually-created interest's source to 'manual' so the
|
||||
// rep doesn't have to remember to pick it (mirrors the same
|
||||
// default on client-form.tsx). Inquiry-inbox / website conversion
|
||||
// flows can override via prefill once that path lands here.
|
||||
source: 'manual',
|
||||
reminderEnabled: false,
|
||||
tagIds: [],
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@ import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
|
||||
import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab';
|
||||
import { QualificationChecklist } from '@/components/interests/qualification-checklist';
|
||||
import { PaymentsSection } from '@/components/interests/payments-section';
|
||||
import { StageGuidanceCard } from '@/components/interests/stage-guidance-card';
|
||||
import { SkipAheadBanner } from '@/components/interests/skip-ahead-banner';
|
||||
import { InterestBerthStatusBanner } from '@/components/interests/interest-berth-status-banner';
|
||||
import { InterestContractTab } from '@/components/interests/interest-contract-tab';
|
||||
@@ -59,7 +58,12 @@ import { useConfirmation } from '@/hooks/use-confirmation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type InterestPatchField = 'leadCategory' | 'source';
|
||||
type InterestPatchField =
|
||||
| 'leadCategory'
|
||||
| 'source'
|
||||
| 'desiredLengthFt'
|
||||
| 'desiredWidthFt'
|
||||
| 'desiredDraftFt';
|
||||
|
||||
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
|
||||
value: c,
|
||||
@@ -381,8 +385,9 @@ function MilestoneSection({
|
||||
<Icon className={cn('size-4', isActive ? 'text-brand-600' : 'text-muted-foreground')} />
|
||||
<h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>
|
||||
{isActive ? (
|
||||
<span className="rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-700">
|
||||
Next
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-brand-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-white shadow-sm">
|
||||
<span className="size-1.5 rounded-full bg-white/90" aria-hidden />
|
||||
Next step
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -844,15 +849,22 @@ function OverviewTab({
|
||||
depositExpectedAmount={interest.depositExpectedAmount ?? null}
|
||||
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
|
||||
/>
|
||||
) : (
|
||||
// §7.2: replace the empty Payments slot with a stage-aware
|
||||
// "next step" card on pre-reservation stages so the rep gets
|
||||
// an actionable prompt instead of dead space.
|
||||
<StageGuidanceCard
|
||||
stage={interest.pipelineStage as PipelineStage}
|
||||
hasLinkedBerth={(interest.linkedBerthCount ?? 0) > 0}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
{/* Pre-reservation: the dedicated "Next step" guidance card was
|
||||
removed in favour of a brighter NEXT STEP pill on the active
|
||||
MilestoneSection below (it already owns the workflow actions —
|
||||
two surfaces was redundant). Nurturing keeps a slim helper
|
||||
since no milestone is naturally "current" while a deal is
|
||||
paused. */}
|
||||
{interest.pipelineStage === 'nurturing' ? (
|
||||
<div className="rounded-xl border bg-card p-4 text-sm">
|
||||
<p className="font-medium text-foreground">Deal is on nurture</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Schedule a follow-up reminder or log a contact when the prospect re-engages, then move
|
||||
them back to Qualified.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Sales-process milestones — phase-aware so the user only sees
|
||||
what's actionable now. Past milestones collapse into a tight
|
||||
@@ -1007,6 +1019,41 @@ function OverviewTab({
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Berth requirements — desired length / width / draft. Editable
|
||||
inline so reps can capture or correct a buyer's needs without
|
||||
leaving the Overview tab. These values drive the auto-tick on
|
||||
the "Dimensions confirmed" qualification row + the
|
||||
BerthRecommenderPanel rankings below. */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Berth requirements</h3>
|
||||
<dl>
|
||||
<EditableRow label="Desired length (ft)">
|
||||
<InlineEditableField
|
||||
value={interest.desiredLengthFt ?? null}
|
||||
onSave={save('desiredLengthFt')}
|
||||
placeholder="e.g. 60"
|
||||
emptyText="—"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Desired width (ft)">
|
||||
<InlineEditableField
|
||||
value={interest.desiredWidthFt ?? null}
|
||||
onSave={save('desiredWidthFt')}
|
||||
placeholder="e.g. 25"
|
||||
emptyText="—"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Desired draft (ft)">
|
||||
<InlineEditableField
|
||||
value={interest.desiredDraftFt ?? null}
|
||||
onSave={save('desiredDraftFt')}
|
||||
placeholder="e.g. 6"
|
||||
emptyText="—"
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Reminder */}
|
||||
{interest.reminderEnabled && (
|
||||
<div className="space-y-1">
|
||||
@@ -1102,6 +1149,7 @@ function OverviewTab({
|
||||
desiredWidthFt={toNum(interest.desiredWidthFt)}
|
||||
desiredDraftFt={toNum(interest.desiredDraftFt)}
|
||||
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
|
||||
linkedBerthCount={interest.linkedBerthCount ?? 0}
|
||||
/>
|
||||
{confirmDialog}
|
||||
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
|
||||
@@ -1197,7 +1245,7 @@ export function getInterestTabs({
|
||||
},
|
||||
{
|
||||
id: 'recommendations',
|
||||
label: 'Recommendations',
|
||||
label: 'Berth Recommendations',
|
||||
content: (
|
||||
<BerthRecommenderPanel
|
||||
interestId={interestId}
|
||||
|
||||
@@ -109,14 +109,19 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) {
|
||||
|
||||
return (
|
||||
<div className="relative space-y-0">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
|
||||
|
||||
{events.map((event) => {
|
||||
{events.map((event, idx) => {
|
||||
const actor = actorLabel(event);
|
||||
const isAuto = event.userId === 'system';
|
||||
const isLast = idx === events.length - 1;
|
||||
return (
|
||||
<div key={event.id} className="relative flex gap-4 pb-6">
|
||||
{/* Vertical line — only between bubbles, never trailing past the last. */}
|
||||
{!isLast && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-4 top-8 bottom-0 -translate-x-1/2 w-px bg-border"
|
||||
/>
|
||||
)}
|
||||
{/* Icon */}
|
||||
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-background border">
|
||||
{eventIcon(event)}
|
||||
|
||||
@@ -21,6 +21,9 @@ interface QualificationRow {
|
||||
confirmedBy: string | null;
|
||||
notes: string | null;
|
||||
autoSatisfied: boolean;
|
||||
/** Human-readable explanation of what data drove auto-satisfaction
|
||||
* (e.g. "Desired: 60 × 25 × 6 ft"). Empty when not auto-satisfied. */
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
interface QualificationResponse {
|
||||
@@ -104,9 +107,20 @@ export function QualificationChecklist({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-1.5">
|
||||
{criteria.map((c) => (
|
||||
<li key={c.key} className="flex items-start gap-2.5">
|
||||
<li
|
||||
key={c.key}
|
||||
className={cn(
|
||||
'flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors',
|
||||
// Unconfirmed rows get a subtle amber accent (left border +
|
||||
// tinted background) so reps can scan the checklist and
|
||||
// immediately see what's outstanding. Confirmed rows stay
|
||||
// muted with line-through; auto-satisfied rows are functionally
|
||||
// confirmed and follow the confirmed styling.
|
||||
!c.confirmed && 'border-l-2 border-warning bg-warning-bg/40',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`qual-${c.key}`}
|
||||
checked={c.confirmed}
|
||||
@@ -125,7 +139,7 @@ export function QualificationChecklist({
|
||||
className={cn(
|
||||
'flex-1 text-sm',
|
||||
c.autoSatisfied ? 'cursor-default' : 'cursor-pointer',
|
||||
c.confirmed ? 'text-foreground' : 'text-foreground/90',
|
||||
c.confirmed ? 'text-muted-foreground' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
@@ -146,6 +160,11 @@ export function QualificationChecklist({
|
||||
{c.description ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>
|
||||
) : null}
|
||||
{c.autoSatisfied && c.evidence ? (
|
||||
<p className="mt-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{c.evidence}
|
||||
</p>
|
||||
) : null}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -60,7 +60,11 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{/* shadcn's default CardContent ships with `pt-0 sm:pt-0` because it
|
||||
assumes a CardHeader sits above. This card is intentionally
|
||||
header-less, so we restore symmetric padding (`pt-` matches `p-`)
|
||||
at both base and `sm:` breakpoints. */}
|
||||
<CardContent className="space-y-3 p-4 pt-4 sm:p-6 sm:pt-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Need more info before drafting the EOI?</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -19,6 +19,9 @@ interface AppShellProps {
|
||||
isSuperAdmin: boolean;
|
||||
user: NonNullable<SidebarProps['user']>;
|
||||
ports: TopbarProps['ports'];
|
||||
/** Per-port logo URLs resolved server-side. Sidebar picks the entry
|
||||
* matching the currently-active port from the UI store. */
|
||||
portLogoUrls: Record<string, string | null>;
|
||||
/**
|
||||
* Server-rendered form-factor hint (from the request User-Agent). The
|
||||
* shell mounts the matching tree on first render so we never paint the
|
||||
@@ -59,6 +62,7 @@ export function AppShell({
|
||||
isSuperAdmin,
|
||||
user,
|
||||
ports,
|
||||
portLogoUrls,
|
||||
initialFormFactor,
|
||||
children,
|
||||
}: AppShellProps) {
|
||||
@@ -83,7 +87,13 @@ export function AppShell({
|
||||
<MobileTopbar />
|
||||
</>
|
||||
) : (
|
||||
<Sidebar portRoles={portRoles} isSuperAdmin={isSuperAdmin} user={user} ports={ports} />
|
||||
<Sidebar
|
||||
portRoles={portRoles}
|
||||
isSuperAdmin={isSuperAdmin}
|
||||
user={user}
|
||||
ports={ports}
|
||||
portLogoUrls={portLogoUrls}
|
||||
/>
|
||||
);
|
||||
|
||||
const footer = isMobile ? (
|
||||
|
||||
@@ -41,15 +41,16 @@ import type { UserPortRole } from '@/lib/db/schema/users';
|
||||
import type { Role } from '@/lib/db/schema/users';
|
||||
import type { Port } from '@/lib/db/schema/ports';
|
||||
|
||||
const LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
|
||||
interface SidebarProps {
|
||||
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
|
||||
isSuperAdmin?: boolean;
|
||||
user?: { name: string; email: string };
|
||||
/** Ports the user has access to. Drives the footer port switcher. */
|
||||
ports?: Port[];
|
||||
/** Per-port logo URLs resolved server-side in the dashboard layout.
|
||||
* The sidebar header swaps to the current port's logo via the UI
|
||||
* store's `currentPortId`. Null entries render the wordmark fallback. */
|
||||
portLogoUrls?: Record<string, string | null>;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
@@ -103,6 +104,22 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Insights',
|
||||
marinaRequired: true,
|
||||
umamiRequired: true,
|
||||
items: [
|
||||
// Marketing / Umami integration. Distinct from the main dashboard
|
||||
// (which is sales-focused) so the audience and the metrics don't
|
||||
// compete for visual real estate. Whole section is hidden when
|
||||
// Umami isn't wired up — see SidebarContent.
|
||||
{
|
||||
href: `${base}/website-analytics`,
|
||||
label: 'Website analytics',
|
||||
icon: Globe,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Documents',
|
||||
marinaRequired: true,
|
||||
@@ -127,22 +144,6 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Insights',
|
||||
marinaRequired: true,
|
||||
umamiRequired: true,
|
||||
items: [
|
||||
// Marketing / Umami integration. Distinct from the main dashboard
|
||||
// (which is sales-focused) so the audience and the metrics don't
|
||||
// compete for visual real estate. Whole section is hidden when
|
||||
// Umami isn't wired up — see SidebarContent.
|
||||
{
|
||||
href: `${base}/website-analytics`,
|
||||
label: 'Website analytics',
|
||||
icon: Globe,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Communication',
|
||||
marinaRequired: true,
|
||||
@@ -236,6 +237,8 @@ function SidebarContent({
|
||||
hasResidentialAccess,
|
||||
user,
|
||||
ports,
|
||||
currentPort,
|
||||
currentLogoUrl,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
@@ -247,6 +250,8 @@ function SidebarContent({
|
||||
hasResidentialAccess: boolean;
|
||||
user?: SidebarProps['user'];
|
||||
ports?: Port[];
|
||||
currentPort: Port | null;
|
||||
currentLogoUrl: string | null;
|
||||
/** When provided, renders the collapse toggle row above the user footer (desktop). */
|
||||
onToggleCollapse?: () => void;
|
||||
}) {
|
||||
@@ -295,15 +300,21 @@ function SidebarContent({
|
||||
collapsed ? 'h-16 px-2' : 'h-24 px-4',
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={LOGO_URL}
|
||||
alt="Port Nimara"
|
||||
width={collapsed ? 40 : 72}
|
||||
height={collapsed ? 40 : 72}
|
||||
className="rounded-full shadow-sm"
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
{currentLogoUrl ? (
|
||||
<Image
|
||||
src={currentLogoUrl}
|
||||
alt={currentPort?.name ?? 'Logo'}
|
||||
width={collapsed ? 40 : 72}
|
||||
height={collapsed ? 40 : 72}
|
||||
className="rounded-full shadow-sm"
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-12 items-center px-3 text-sm font-semibold text-slate-700">
|
||||
{currentPort?.name ?? 'CRM'}
|
||||
</div>
|
||||
)}
|
||||
{onToggleCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -439,12 +450,21 @@ function SidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: SidebarProps) {
|
||||
export function Sidebar({
|
||||
portRoles,
|
||||
isSuperAdmin = false,
|
||||
user,
|
||||
ports,
|
||||
portLogoUrls,
|
||||
}: SidebarProps) {
|
||||
// Sidebar collapse removed — design preference is the always-expanded
|
||||
// form. Forcibly false; the store flag stays for backwards-compat with
|
||||
// any code still reading it.
|
||||
const sidebarCollapsed = false;
|
||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const currentPortId = useUIStore((s) => s.currentPortId);
|
||||
const currentPort = ports?.find((p) => p.id === currentPortId) ?? ports?.[0] ?? null;
|
||||
const currentLogoUrl = currentPortId ? (portLogoUrls?.[currentPortId] ?? null) : null;
|
||||
|
||||
// Super admins see every section regardless of role rows.
|
||||
const hasAdminAccess =
|
||||
@@ -478,6 +498,8 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: Sideba
|
||||
hasResidentialAccess={hasResidentialAccess}
|
||||
user={user}
|
||||
ports={ports}
|
||||
currentPort={currentPort}
|
||||
currentLogoUrl={currentLogoUrl}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,62 @@ function toLocalDatetimeLocal(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Date relative to "now" for the quick-pick chips. Day-based
|
||||
* presets land on the user's preferred time-of-day (`digestTimeOfDay`
|
||||
* from user_profiles.preferences) — same source the default dueAt uses.
|
||||
* Hour-based presets use the current time + N hours.
|
||||
*/
|
||||
function buildPresetDate(
|
||||
preset:
|
||||
| { kind: 'hours'; hours: number }
|
||||
| { kind: 'tomorrow' }
|
||||
| { kind: 'in_days'; days: number }
|
||||
| { kind: 'next_monday' },
|
||||
timeOfDay: string | null,
|
||||
): Date {
|
||||
const now = new Date();
|
||||
let h = 9;
|
||||
let m = 0;
|
||||
if (timeOfDay && /^\d{2}:\d{2}$/.test(timeOfDay)) {
|
||||
const [hh = '09', mm = '00'] = timeOfDay.split(':');
|
||||
const parsedH = Number.parseInt(hh, 10);
|
||||
const parsedM = Number.parseInt(mm, 10);
|
||||
if (Number.isFinite(parsedH) && parsedH >= 0 && parsedH <= 23) h = parsedH;
|
||||
if (Number.isFinite(parsedM) && parsedM >= 0 && parsedM <= 59) m = parsedM;
|
||||
}
|
||||
if (preset.kind === 'hours') {
|
||||
return new Date(now.getTime() + preset.hours * 60 * 60 * 1000);
|
||||
}
|
||||
if (preset.kind === 'tomorrow') {
|
||||
const t = new Date(now);
|
||||
t.setDate(t.getDate() + 1);
|
||||
t.setHours(h, m, 0, 0);
|
||||
return t;
|
||||
}
|
||||
if (preset.kind === 'in_days') {
|
||||
const t = new Date(now);
|
||||
t.setDate(t.getDate() + preset.days);
|
||||
t.setHours(h, m, 0, 0);
|
||||
return t;
|
||||
}
|
||||
// next_monday
|
||||
const t = new Date(now);
|
||||
const daysUntilMonday = (8 - t.getDay()) % 7 || 7;
|
||||
t.setDate(t.getDate() + daysUntilMonday);
|
||||
t.setHours(h, m, 0, 0);
|
||||
return t;
|
||||
}
|
||||
|
||||
const DUE_PRESETS = [
|
||||
{ label: 'In 1 hour', spec: { kind: 'hours', hours: 1 } as const },
|
||||
{ label: 'In 4 hours', spec: { kind: 'hours', hours: 4 } as const },
|
||||
{ label: 'Tomorrow', spec: { kind: 'tomorrow' } as const },
|
||||
{ label: 'In 3 days', spec: { kind: 'in_days', days: 3 } as const },
|
||||
{ label: 'Next week', spec: { kind: 'next_monday' } as const },
|
||||
{ label: 'In 2 weeks', spec: { kind: 'in_days', days: 14 } as const },
|
||||
] as const;
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
displayName: string;
|
||||
@@ -230,6 +286,24 @@ function ReminderFormBody({
|
||||
<div className="grid grid-cols-[2fr_1fr] gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-due">Due Date & Time</Label>
|
||||
{/* Quick-pick chips. Same idiom as the snooze dialog so reps
|
||||
don't have to think about month-day-time for the 80%
|
||||
common cases. Each chip writes the formatted datetime
|
||||
into the input, leaving the rep free to tweak. */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DUE_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDueAt(toLocalDatetimeLocal(buildPresetDate(p.spec, userTodPref)))
|
||||
}
|
||||
className="rounded-full border bg-background px-2.5 py-0.5 text-xs font-medium text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
id="reminder-due"
|
||||
type="datetime-local"
|
||||
|
||||
@@ -5,9 +5,6 @@ import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Camera, Loader2, RotateCcw, AlertTriangle, CheckCircle2, Save } from 'lucide-react';
|
||||
|
||||
const LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -322,7 +319,15 @@ function VerifyForm({
|
||||
|
||||
// ─── Shell ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ScanShell() {
|
||||
interface ScanShellProps {
|
||||
/** Per-port brand logo resolved server-side by the page wrapper.
|
||||
* Null hides the logo block — we never fall back to another tenant's
|
||||
* imagery. */
|
||||
logoUrl?: string | null;
|
||||
portName?: string | null;
|
||||
}
|
||||
|
||||
export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
|
||||
const router = useRouter();
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -505,15 +510,17 @@ export function ScanShell() {
|
||||
{/* Brand header - logo centered, page title underneath. Establishes
|
||||
the standalone identity (this is the PWA home for the scanner). */}
|
||||
<header className="flex flex-col items-center gap-3">
|
||||
<Image
|
||||
src={LOGO_URL}
|
||||
alt="Port Nimara"
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-full shadow-md"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={portName ?? 'Logo'}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-full shadow-md"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Scan a receipt</h1>
|
||||
{state.kind !== 'idle' ? (
|
||||
|
||||
@@ -650,23 +650,27 @@ function ZeroState({ query, portSlug }: { query: string; portSlug: string | null
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground/70 mb-2">Quick create</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Land on the list page with `?create=1&prefill_name=…`; the list
|
||||
page's `useCreateFromUrl` hook pops the create sheet with the
|
||||
search query pre-filled. The bogus `/<entity>/new` URLs used
|
||||
previously hit the `[id]` route and rendered "not found". */}
|
||||
<QuickCreateButton
|
||||
icon={User}
|
||||
label={`New client "${query}"`}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/clients/new?fullName=${encodeURIComponent(query)}` as any}
|
||||
href={`/${portSlug}/clients?create=1&prefill_name=${encodeURIComponent(query)}` as any}
|
||||
/>
|
||||
<QuickCreateButton
|
||||
icon={Ship}
|
||||
label={`New yacht "${query}"`}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/yachts/new?name=${encodeURIComponent(query)}` as any}
|
||||
href={`/${portSlug}/yachts?create=1&prefill_name=${encodeURIComponent(query)}` as any}
|
||||
/>
|
||||
<QuickCreateButton
|
||||
icon={Building2}
|
||||
label={`New company "${query}"`}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/companies/new?name=${encodeURIComponent(query)}` as any}
|
||||
href={`/${portSlug}/companies?create=1&prefill_name=${encodeURIComponent(query)}` as any}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
|
||||
|
||||
/**
|
||||
* Per-user toggle list for dashboard widgets. The dashboard reads the
|
||||
* same `useDashboardWidgets` hook, so flipping a switch here causes the
|
||||
* dashboard to reflow on the next visit (or instantly if the user has
|
||||
* both pages open in different tabs — TanStack Query's optimistic
|
||||
* update + invalidate handles the cache sync).
|
||||
*
|
||||
* Mounted from UserSettings under the id `dashboard` so the dashboard
|
||||
* "Customize" button can deep-link via `/settings#dashboard`.
|
||||
*/
|
||||
export function DashboardWidgetsCard() {
|
||||
const { allWidgets, visibility, setVisible, setAll, isSaving } = useDashboardWidgets();
|
||||
|
||||
const visibleCount = Object.values(visibility).filter(Boolean).length;
|
||||
const allVisible = visibleCount === allWidgets.length;
|
||||
const allHidden = visibleCount === 0;
|
||||
|
||||
return (
|
||||
<Card id="dashboard">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle>Dashboard widgets</CardTitle>
|
||||
<CardDescription>
|
||||
Pick which cards show up on your dashboard. Hidden cards leave no empty space — the
|
||||
layout reflows to fill the available width.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAll(true)}
|
||||
disabled={allVisible || isSaving}
|
||||
>
|
||||
Show all
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAll(false)}
|
||||
disabled={allHidden || isSaving}
|
||||
>
|
||||
Hide all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
{allWidgets.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
className="flex items-start justify-between gap-4 rounded-md px-3 py-2 hover:bg-accent/40"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{w.label}</div>
|
||||
<p className="text-xs text-muted-foreground">{w.description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={`Show ${w.label}`}
|
||||
checked={visibility[w.id] ?? false}
|
||||
disabled={isSaving}
|
||||
onCheckedChange={(checked) => setVisible(w.id, checked)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Save, KeyRound, Globe, Upload } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
@@ -16,7 +17,6 @@ import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
||||
import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog';
|
||||
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
|
||||
import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form';
|
||||
import { DashboardWidgetsCard } from '@/components/settings/dashboard-widgets-card';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
@@ -34,6 +34,7 @@ interface MeResponse {
|
||||
}
|
||||
|
||||
export function UserSettings() {
|
||||
const router = useRouter();
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
@@ -155,6 +156,10 @@ export function UserSettings() {
|
||||
},
|
||||
});
|
||||
setMessage('Profile saved');
|
||||
// Topbar + sidebar `user` props come from the dashboard server
|
||||
// layout reading userProfiles.displayName — refresh so the new
|
||||
// name flows back through without a hard reload.
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
setMessage(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
@@ -191,6 +196,7 @@ export function UserSettings() {
|
||||
await apiFetch('/api/v1/me/email', { method: 'PATCH', body: { email } });
|
||||
setOriginalEmail(email);
|
||||
setEmailMsg('Email updated. Use the new address next time you sign in.');
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
setEmailMsg(err instanceof Error ? err.message : 'Failed to update email');
|
||||
} finally {
|
||||
@@ -369,8 +375,6 @@ export function UserSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DashboardWidgetsCard />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
@@ -10,6 +11,8 @@ interface DevFlags {
|
||||
isDev: boolean;
|
||||
}
|
||||
|
||||
const DISMISS_KEY = 'pn-crm.devBanner.dismissed';
|
||||
|
||||
/**
|
||||
* Single-line warning banner shown across the app whenever a dev-mode
|
||||
* safety net is active (today: `EMAIL_REDIRECT_TO`). Sticky at the top
|
||||
@@ -19,18 +22,35 @@ interface DevFlags {
|
||||
* Production hides the banner entirely because env.ts refuses to boot
|
||||
* with EMAIL_REDIRECT_TO set when NODE_ENV=production — the flag is
|
||||
* only ever non-null in dev / staging.
|
||||
*
|
||||
* Dismissal is persisted in localStorage keyed by the redirect address —
|
||||
* changing `EMAIL_REDIRECT_TO` re-shows the banner so the new target
|
||||
* can't be silently inherited.
|
||||
*/
|
||||
export function DevModeBanner() {
|
||||
const { data } = useQuery<{ data: DevFlags }>({
|
||||
queryKey: ['internal', 'dev-flags'],
|
||||
queryFn: () => apiFetch<{ data: DevFlags }>('/api/v1/internal/dev-flags'),
|
||||
staleTime: 5 * 60_000,
|
||||
// Don't refetch on focus; the flag changes only on a restart.
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const redirect = data?.data?.emailRedirectTo;
|
||||
if (!redirect) return null;
|
||||
const [overrideDismissed, setOverrideDismissed] = useState(false);
|
||||
const persistedDismissed =
|
||||
typeof window !== 'undefined' && !!redirect
|
||||
? window.localStorage.getItem(DISMISS_KEY) === redirect
|
||||
: false;
|
||||
const dismissed = overrideDismissed || persistedDismissed;
|
||||
|
||||
if (!redirect || dismissed) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(DISMISS_KEY, redirect);
|
||||
}
|
||||
setOverrideDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -42,6 +62,14 @@ export function DevModeBanner() {
|
||||
<span>
|
||||
Dev mode: outbound emails redirected to <code className="font-mono">{redirect}</code>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss dev mode banner"
|
||||
className="ml-2 inline-flex size-5 shrink-0 items-center justify-center rounded hover:bg-amber-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -214,9 +214,13 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }:
|
||||
No activity matches the current filters.
|
||||
</div>
|
||||
) : (
|
||||
<ol className="relative border-l border-muted-foreground/20 ml-3 pl-6 space-y-4 py-2">
|
||||
<ol className="relative ml-3 pl-6 space-y-4 py-2">
|
||||
{groups.map((group, gi) => (
|
||||
<SessionGroupItem key={`${group.actorKey}-${gi}`} group={group} />
|
||||
<SessionGroupItem
|
||||
key={`${group.actorKey}-${gi}`}
|
||||
group={group}
|
||||
isLast={gi === groups.length - 1}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
@@ -224,15 +228,25 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }:
|
||||
);
|
||||
}
|
||||
|
||||
function SessionGroupItem({ group }: { group: SessionGroup }) {
|
||||
function SessionGroupItem({ group, isLast }: { group: SessionGroup; isLast: boolean }) {
|
||||
const [expanded, setExpanded] = useState(group.rows.length <= 3);
|
||||
const first = group.rows[0]!;
|
||||
const created = new Date(first.createdAt);
|
||||
const ago = formatDistanceToNow(created, { addSuffix: true });
|
||||
|
||||
// Vertical connector — runs from below this bubble down to the next item,
|
||||
// omitted on the last item so the line never trails past the last bubble.
|
||||
const connector = !isLast ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[-26px] top-3 bottom-[-1rem] w-px bg-muted-foreground/20"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (group.rows.length === 1) {
|
||||
return (
|
||||
<li className="relative">
|
||||
{connector}
|
||||
<span className="absolute left-[-31px] top-1 h-2.5 w-2.5 rounded-full bg-primary/70 ring-2 ring-background" />
|
||||
<RowBody row={first} actor={group.actorLabel} ago={ago} />
|
||||
</li>
|
||||
@@ -241,6 +255,7 @@ function SessionGroupItem({ group }: { group: SessionGroup }) {
|
||||
|
||||
return (
|
||||
<li className="relative">
|
||||
{connector}
|
||||
<span className="absolute left-[-31px] top-1 h-2.5 w-2.5 rounded-full bg-primary/70 ring-2 ring-background" />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { TrendingUp, TrendingDown, Minus, Info } from 'lucide-react';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface KPITileProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -7,10 +9,19 @@ interface KPITileProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: React.ReactNode;
|
||||
/** Signed delta vs. prior period; positive = green, negative = red, undefined = no chip. */
|
||||
delta?: number;
|
||||
/** Optional suffix appended to the delta value (e.g. '%' for percentage deltas). */
|
||||
deltaSuffix?: string;
|
||||
/** Pre-rendered sparkline (recharts) - caller decides shape. */
|
||||
sparkline?: React.ReactNode;
|
||||
/** Optional accent stripe colour token; defaults to brand. */
|
||||
accent?: 'brand' | 'success' | 'warning' | 'mint' | 'teal' | 'purple';
|
||||
/** Optional explainer rendered in a Popover when the user clicks the (i)
|
||||
* icon next to the title. Keep it short — one sentence ideally. */
|
||||
tooltip?: React.ReactNode;
|
||||
/** For metrics where lower is better (bounce rate, error count). The
|
||||
* delta number is still shown verbatim, but the colour flips: a
|
||||
* negative delta renders green (improvement) instead of red. */
|
||||
lowerIsBetter?: boolean;
|
||||
}
|
||||
|
||||
const ACCENT_STRIPES: Record<NonNullable<KPITileProps['accent']>, string> = {
|
||||
@@ -26,19 +37,27 @@ export function KPITile({
|
||||
title,
|
||||
value,
|
||||
delta,
|
||||
deltaSuffix,
|
||||
sparkline,
|
||||
accent = 'brand',
|
||||
tooltip,
|
||||
lowerIsBetter = false,
|
||||
className,
|
||||
...props
|
||||
}: KPITileProps) {
|
||||
// Colour logic is independent of the displayed number. For normal
|
||||
// metrics, positive=good (green); for `lowerIsBetter` metrics, flip so
|
||||
// a drop renders green.
|
||||
const isImprovement = typeof delta === 'number' ? (lowerIsBetter ? delta < 0 : delta > 0) : false;
|
||||
const isRegression = typeof delta === 'number' ? (lowerIsBetter ? delta > 0 : delta < 0) : false;
|
||||
const deltaClass =
|
||||
typeof delta === 'number'
|
||||
? delta > 0
|
||||
typeof delta !== 'number'
|
||||
? ''
|
||||
: isImprovement
|
||||
? 'text-success'
|
||||
: delta < 0
|
||||
: isRegression
|
||||
? 'text-error'
|
||||
: 'text-muted-foreground'
|
||||
: '';
|
||||
: 'text-muted-foreground';
|
||||
const deltaPrefix = typeof delta === 'number' ? (delta > 0 ? '+' : '') : '';
|
||||
|
||||
return (
|
||||
@@ -53,18 +72,50 @@ export function KPITile({
|
||||
<div className={cn('absolute inset-x-0 top-0 h-1', ACCENT_STRIPES[accent])} aria-hidden />
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||
{title}
|
||||
<div className="flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||
<span>{title}</span>
|
||||
{tooltip ? (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
type="button"
|
||||
aria-label={`What is ${title}?`}
|
||||
className="inline-flex size-3.5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
|
||||
>
|
||||
<Info className="size-3" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-64 text-xs leading-relaxed normal-case tracking-normal text-muted-foreground"
|
||||
>
|
||||
{tooltip}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-lg font-semibold tabular-nums text-foreground sm:mt-2 sm:text-2xl">
|
||||
{value}
|
||||
<div className="mt-1 flex items-baseline gap-2 sm:mt-2">
|
||||
<span className="truncate text-lg font-semibold tabular-nums text-foreground sm:text-2xl">
|
||||
{value}
|
||||
</span>
|
||||
{typeof delta === 'number' ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-0.5 text-xs font-medium',
|
||||
deltaClass,
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? (
|
||||
<TrendingUp className="size-3" aria-hidden />
|
||||
) : delta < 0 ? (
|
||||
<TrendingDown className="size-3" aria-hidden />
|
||||
) : (
|
||||
<Minus className="size-3" aria-hidden />
|
||||
)}
|
||||
{deltaPrefix}
|
||||
{delta}
|
||||
{deltaSuffix ?? ''}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{typeof delta === 'number' ? (
|
||||
<div className={cn('mt-1 text-xs font-medium', deltaClass)}>
|
||||
{deltaPrefix}
|
||||
{delta}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{sparkline ? <div className="h-12 w-24 shrink-0 opacity-80">{sparkline}</div> : null}
|
||||
</div>
|
||||
|
||||
@@ -75,6 +75,14 @@ interface YachtFormProps {
|
||||
* just-spawned yacht in scope.
|
||||
*/
|
||||
onCreated?: (yacht: { id: string; name: string }) => void | Promise<void>;
|
||||
/**
|
||||
* Optional initial values for the create flow — used by the global
|
||||
* command-search quick-create ("New yacht 'matthew'" → lands on
|
||||
* `/yachts?create=1&prefill_name=matthew`). Ignored in edit mode.
|
||||
*/
|
||||
prefill?: {
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type YachtStatus = 'active' | 'retired' | 'sold_away';
|
||||
@@ -86,6 +94,7 @@ export function YachtForm({
|
||||
initialOwner,
|
||||
createExtras,
|
||||
onCreated,
|
||||
prefill,
|
||||
}: YachtFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!yacht;
|
||||
@@ -139,14 +148,14 @@ export function YachtForm({
|
||||
});
|
||||
} else if (!yacht && open) {
|
||||
reset({
|
||||
name: '',
|
||||
name: prefill?.name ?? '',
|
||||
status: 'active',
|
||||
tagIds: [],
|
||||
...(initialOwner ? { owner: initialOwner } : {}),
|
||||
});
|
||||
}
|
||||
setFormError(null);
|
||||
}, [yacht, open, reset, initialOwner]);
|
||||
}, [yacht, open, reset, initialOwner, prefill]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: CreateYachtInput) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { Plus, Archive, Tag as TagIcon, TagsIcon } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
@@ -51,6 +51,16 @@ export function YachtList() {
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
useCreateFromUrl(() => setCreateOpen(true));
|
||||
|
||||
// Quick-create from the global command-search lands here with
|
||||
// `?create=1&prefill_name=…`. Hydrate the yacht-form's name field so
|
||||
// the rep doesn't retype.
|
||||
const searchParams = useSearchParams();
|
||||
const createPrefill = useMemo(() => {
|
||||
const name = searchParams?.get('prefill_name');
|
||||
return name ? { name } : undefined;
|
||||
}, [searchParams]);
|
||||
|
||||
const [editYacht, setEditYacht] = useState<YachtRow | null>(null);
|
||||
const [archiveYacht, setArchiveYacht] = useState<YachtRow | null>(null);
|
||||
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
|
||||
@@ -269,7 +279,7 @@ export function YachtList() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<YachtForm open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<YachtForm open={createOpen} onOpenChange={setCreateOpen} prefill={createPrefill} />
|
||||
|
||||
{editYacht && (
|
||||
<YachtForm
|
||||
|
||||
@@ -77,14 +77,40 @@ function buildAuth() {
|
||||
// through the shared SMTP infra so EMAIL_REDIRECT_TO honours it
|
||||
// in dev.
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
const { sendEmail } = await import('@/lib/email');
|
||||
const subject = 'Reset your Port Nimara CRM password';
|
||||
const html = `
|
||||
<p>Hi ${user.name || 'there'},</p>
|
||||
<p>You requested a password reset for your Port Nimara CRM account.</p>
|
||||
<p><a href="${url}">Click here to set a new password</a> — the link expires in 1 hour.</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`;
|
||||
const [{ sendEmail }, { renderShell, safeUrl }, { resolveAuthShellBranding }] =
|
||||
await Promise.all([
|
||||
import('@/lib/email'),
|
||||
import('@/lib/email/shell'),
|
||||
import('@/lib/email/auth-shell-branding'),
|
||||
]);
|
||||
|
||||
const branding = await resolveAuthShellBranding();
|
||||
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({
|
||||
title: subject,
|
||||
body,
|
||||
branding: branding
|
||||
? {
|
||||
logoUrl: branding.logoUrl,
|
||||
backgroundUrl: branding.backgroundUrl,
|
||||
primaryColor: null,
|
||||
emailHeaderHtml: null,
|
||||
emailFooterHtml: null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const text = `Reset your password: ${url}`;
|
||||
await sendEmail(user.email, subject, html, undefined, text);
|
||||
},
|
||||
|
||||
@@ -150,6 +150,13 @@ export const documentSends = pgTable(
|
||||
bounceStatus: text('bounce_status'), // 'hard' | 'soft' | 'ooo'
|
||||
bounceReason: text('bounce_reason'),
|
||||
bounceDetectedAt: timestamp('bounce_detected_at', { withTimezone: true }),
|
||||
// Phase 4b — email open tracking. When `trackOpens` is true the send
|
||||
// includes a 1×1 pixel pointing at /api/public/email-pixel/[sendId].
|
||||
// `firstOpenedAt` + `openCount` are denormalised aggregates so the
|
||||
// sends list can render an "opened" pill without a JOIN.
|
||||
trackOpens: boolean('track_opens').notNull().default(false),
|
||||
firstOpenedAt: timestamp('first_opened_at', { withTimezone: true }),
|
||||
openCount: integer('open_count').notNull().default(0),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_ds_client').on(t.clientId, t.sentAt),
|
||||
@@ -164,9 +171,40 @@ export const documentSends = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-open log for emails with `trackOpens=true`. The 1×1 pixel
|
||||
* endpoint inserts here on every fetch (Apple Mail privacy proxy will
|
||||
* over-count; most other clients under-count when images are blocked —
|
||||
* this is the universal email-tracking caveat). Cached aggregates on
|
||||
* `document_sends` keep list rendering fast.
|
||||
*/
|
||||
export const documentSendOpens = pgTable(
|
||||
'document_send_opens',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
sendId: text('send_id')
|
||||
.notNull()
|
||||
.references(() => documentSends.id, { onDelete: 'cascade' }),
|
||||
openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
userAgent: text('user_agent'),
|
||||
referer: text('referer'),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_dso_send').on(t.sendId, t.openedAt),
|
||||
index('idx_dso_port').on(t.portId, t.openedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export type Brochure = typeof brochures.$inferSelect;
|
||||
export type NewBrochure = typeof brochures.$inferInsert;
|
||||
export type BrochureVersion = typeof brochureVersions.$inferSelect;
|
||||
export type NewBrochureVersion = typeof brochureVersions.$inferInsert;
|
||||
export type DocumentSend = typeof documentSends.$inferSelect;
|
||||
export type NewDocumentSend = typeof documentSends.$inferInsert;
|
||||
export type DocumentSendOpen = typeof documentSendOpens.$inferSelect;
|
||||
export type NewDocumentSendOpen = typeof documentSendOpens.$inferInsert;
|
||||
|
||||
@@ -31,6 +31,15 @@ export type RolePermissions = {
|
||||
edit: boolean;
|
||||
import: boolean;
|
||||
manage_waiting_list: boolean;
|
||||
/**
|
||||
* Update berth `price` / `priceCurrency` via the dedicated single
|
||||
* (`PATCH /api/v1/berths/[id]/price`) and bulk
|
||||
* (`POST /api/v1/berths/bulk-update-prices`) endpoints. Carved out
|
||||
* from generic `berths.edit` so admins can grant sales reps the
|
||||
* ability to retune prices without exposing the full berth-edit
|
||||
* surface (dimensions, mooring type, etc.). Always audited.
|
||||
*/
|
||||
update_prices: boolean;
|
||||
};
|
||||
documents: {
|
||||
view: boolean;
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
* per-port fixture builders.
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from './index';
|
||||
import { ports } from './schema/ports';
|
||||
import { roles, userProfiles } from './schema/users';
|
||||
import { systemSettings } from './schema/system';
|
||||
import {
|
||||
ALL_PERMISSIONS,
|
||||
DIRECTOR_PERMISSIONS,
|
||||
@@ -28,12 +29,21 @@ export interface BootstrappedPort {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface PortBrandingSeed {
|
||||
logoUrl?: string;
|
||||
emailBackgroundUrl?: string;
|
||||
appName?: string;
|
||||
emailHeaderHtml?: string;
|
||||
emailFooterHtml?: string;
|
||||
}
|
||||
|
||||
export const PORT_DEFINITIONS: Array<{
|
||||
name: string;
|
||||
slug: string;
|
||||
primaryColor: string;
|
||||
defaultCurrency: string;
|
||||
timezone: string;
|
||||
branding?: PortBrandingSeed;
|
||||
}> = [
|
||||
{
|
||||
name: 'Port Nimara',
|
||||
@@ -41,6 +51,12 @@ export const PORT_DEFINITIONS: Array<{
|
||||
primaryColor: '#0F4C81',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Anguilla',
|
||||
branding: {
|
||||
logoUrl:
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png',
|
||||
emailBackgroundUrl: 'https://s3.portnimara.com/images/Overhead_1_blur.png',
|
||||
appName: 'Port Nimara CRM',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Port Amador',
|
||||
@@ -48,6 +64,8 @@ export const PORT_DEFINITIONS: Array<{
|
||||
primaryColor: '#D97706',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Panama',
|
||||
// Branding intentionally left empty — admin uploads their own assets
|
||||
// via /admin/branding rather than inheriting Port Nimara's look.
|
||||
},
|
||||
];
|
||||
|
||||
@@ -75,16 +93,51 @@ export async function seedBootstrap(): Promise<BootstrappedPort[]> {
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
let portId: string | null = null;
|
||||
if (inserted) {
|
||||
console.log(` Port created: ${def.name} (${inserted.id})`);
|
||||
portIds.push({ id: inserted.id, name: def.name, slug: def.slug });
|
||||
portId = inserted.id;
|
||||
} else {
|
||||
const [existing] = await db.select().from(ports).where(eq(ports.slug, def.slug)).limit(1);
|
||||
if (existing) {
|
||||
console.log(` Port exists: ${def.name} (${existing.id})`);
|
||||
portIds.push({ id: existing.id, name: def.name, slug: def.slug });
|
||||
portId = existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Seed branding settings idempotently. `onConflictDoNothing` so an
|
||||
// admin who's tweaked the values via /admin/branding doesn't get
|
||||
// their work overwritten by a re-seed.
|
||||
if (portId && def.branding) {
|
||||
const brandingPairs: Array<[string, unknown]> = [];
|
||||
if (def.branding.logoUrl) brandingPairs.push(['branding_logo_url', def.branding.logoUrl]);
|
||||
if (def.branding.emailBackgroundUrl)
|
||||
brandingPairs.push(['branding_email_background_url', def.branding.emailBackgroundUrl]);
|
||||
if (def.branding.appName) brandingPairs.push(['branding_app_name', def.branding.appName]);
|
||||
if (def.branding.emailHeaderHtml)
|
||||
brandingPairs.push(['branding_email_header_html', def.branding.emailHeaderHtml]);
|
||||
if (def.branding.emailFooterHtml)
|
||||
brandingPairs.push(['branding_email_footer_html', def.branding.emailFooterHtml]);
|
||||
brandingPairs.push(['branding_primary_color', def.primaryColor]);
|
||||
|
||||
for (const [key, value] of brandingPairs) {
|
||||
// Skip when an existing row is already present — preserves admin
|
||||
// edits across re-seeds. Pair (key, portId) is uniquely indexed.
|
||||
const existing = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
|
||||
});
|
||||
if (existing) continue;
|
||||
await db.insert(systemSettings).values({
|
||||
key,
|
||||
value: value as Record<string, unknown>,
|
||||
portId,
|
||||
updatedBy: SUPER_ADMIN_USER_ID,
|
||||
});
|
||||
}
|
||||
console.log(` Branding seeded for ${def.name} (${brandingPairs.length} keys)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── System roles ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ALL_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -102,7 +102,7 @@ export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -181,7 +181,7 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -260,7 +260,7 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -339,7 +339,13 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
|
||||
berths: {
|
||||
view: true,
|
||||
edit: false,
|
||||
import: false,
|
||||
manage_waiting_list: false,
|
||||
update_prices: false,
|
||||
},
|
||||
documents: {
|
||||
view: true,
|
||||
create: false,
|
||||
@@ -421,7 +427,13 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
|
||||
berths: {
|
||||
view: false,
|
||||
edit: false,
|
||||
import: false,
|
||||
manage_waiting_list: false,
|
||||
update_prices: false,
|
||||
},
|
||||
documents: {
|
||||
view: false,
|
||||
create: false,
|
||||
|
||||
@@ -92,6 +92,12 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
||||
const primaryBerth = await getPrimaryBerth(interestId);
|
||||
const berthMooring = primaryBerth?.mooringNumber ?? null;
|
||||
|
||||
// Resolve the port branding app name once so template-fallback drafts
|
||||
// sign off as "{Port} Team" instead of leaking another tenant's name.
|
||||
const { getPortBrandingConfig } = await import('@/lib/services/port-config');
|
||||
const portBrand = await getPortBrandingConfig(portId).catch(() => null);
|
||||
const brandingAppName = portBrand?.appName?.trim() || 'our marina';
|
||||
|
||||
// Fetch last 5 notes
|
||||
const recentNotes = await db
|
||||
.select({ content: interestNotes.content, createdAt: interestNotes.createdAt })
|
||||
@@ -117,6 +123,7 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
||||
context,
|
||||
berthMooring,
|
||||
pipelineStage: interest.pipelineStage,
|
||||
portName: brandingAppName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -253,6 +260,7 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
||||
context,
|
||||
berthMooring,
|
||||
pipelineStage: interest.pipelineStage,
|
||||
portName: brandingAppName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -266,26 +274,28 @@ function buildTemplateDraft(opts: {
|
||||
context: string;
|
||||
berthMooring: string | null;
|
||||
pipelineStage: string;
|
||||
portName: string;
|
||||
}): DraftResult {
|
||||
const { clientName, context, berthMooring, pipelineStage } = opts;
|
||||
const { clientName, context, berthMooring, pipelineStage, portName } = opts;
|
||||
const berthText = berthMooring ? `berth ${berthMooring}` : 'your requested berth';
|
||||
const signoff = `Kind regards,\n${portName} Team`;
|
||||
|
||||
const templates: Record<string, { subject: string; body: string }> = {
|
||||
introduction: {
|
||||
subject: `Welcome to Port Nimara – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nThank you for your interest in Port Nimara. We are delighted to introduce our marina facilities and look forward to discussing how we can accommodate your needs for ${berthText}.\n\nPlease feel free to reach out at any time.\n\nKind regards,\nPort Nimara Team`,
|
||||
subject: `Welcome to ${portName} – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nThank you for your interest in ${portName}. We are delighted to introduce our marina facilities and look forward to discussing how we can accommodate your needs for ${berthText}.\n\nPlease feel free to reach out at any time.\n\n${signoff}`,
|
||||
},
|
||||
follow_up: {
|
||||
subject: `Following up – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nI wanted to follow up regarding your interest in ${berthText}. Please let us know if you have any questions or if there is anything we can assist you with.\n\nWe look forward to hearing from you.\n\nKind regards,\nPort Nimara Team`,
|
||||
body: `Dear ${clientName},\n\nI wanted to follow up regarding your interest in ${berthText}. Please let us know if you have any questions or if there is anything we can assist you with.\n\nWe look forward to hearing from you.\n\n${signoff}`,
|
||||
},
|
||||
stage_update: {
|
||||
subject: `Update on your application – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nWe are pleased to inform you that your application for ${berthText} has progressed to the "${stageLabel(pipelineStage)}" stage.\n\nWe will be in touch shortly with the next steps.\n\nKind regards,\nPort Nimara Team`,
|
||||
body: `Dear ${clientName},\n\nWe are pleased to inform you that your application for ${berthText} has progressed to the "${stageLabel(pipelineStage)}" stage.\n\nWe will be in touch shortly with the next steps.\n\n${signoff}`,
|
||||
},
|
||||
general: {
|
||||
subject: `Message from Port Nimara – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nThank you for your continued interest in Port Nimara. We appreciate your patience and look forward to assisting you with ${berthText}.\n\nKind regards,\nPort Nimara Team`,
|
||||
subject: `Message from ${portName} – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nThank you for your continued interest in ${portName}. We appreciate your patience and look forward to assisting you with ${berthText}.\n\n${signoff}`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export const emailWorker = new Worker(
|
||||
portId,
|
||||
fallback: email.subject,
|
||||
tokens: {
|
||||
portName: portName ?? 'Port Nimara',
|
||||
portName: portName ?? 'the marina',
|
||||
recipientName: firstName,
|
||||
mooringNumber: mooringNumber ?? '',
|
||||
},
|
||||
@@ -83,7 +83,7 @@ export const emailWorker = new Worker(
|
||||
portId,
|
||||
fallback: notification.subject,
|
||||
tokens: {
|
||||
portName: portName ?? 'Port Nimara',
|
||||
portName: portName ?? 'the marina',
|
||||
clientName: fullName,
|
||||
mooringNumber: mooringNumber ?? '',
|
||||
email,
|
||||
|
||||
@@ -83,9 +83,18 @@ export const notificationsWorker = new Worker(
|
||||
const linkHtml = notif.link
|
||||
? `<p><a href="${safeUrl(`${process.env.APP_URL ?? ''}${notif.link}`)}">View in CRM</a></p>`
|
||||
: '';
|
||||
|
||||
// Subject prefix = port branding `appName` so multi-tenant
|
||||
// deploys read "[Port Amador]"/"[Other Marina]" instead of
|
||||
// a hardcoded "[Port Nimara]".
|
||||
const { getPortBrandingConfig } = await import('@/lib/services/port-config');
|
||||
const portBrand = notif.portId
|
||||
? await getPortBrandingConfig(notif.portId).catch(() => null)
|
||||
: null;
|
||||
const prefix = portBrand?.appName?.trim() || 'CRM';
|
||||
await sendEmail(
|
||||
authUser.email,
|
||||
`[Port Nimara] ${notif.title}`,
|
||||
`[${prefix}] ${notif.title}`,
|
||||
`<p>${bodyText}</p>${linkHtml}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { and, between, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { db } from '@/lib/db';
|
||||
import { analyticsSnapshots } from '@/lib/db/schema/insights';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { invoices } from '@/lib/db/schema/financial';
|
||||
import { PIPELINE_STAGES } from '@/lib/constants';
|
||||
import {
|
||||
ALL_RANGES,
|
||||
@@ -26,11 +25,7 @@ import {
|
||||
export { ALL_RANGES, isCustomRange, rangeToBounds };
|
||||
export type { DateRange, PresetDateRange, CustomDateRange };
|
||||
|
||||
export type MetricBase =
|
||||
| 'pipeline_funnel'
|
||||
| 'occupancy_timeline'
|
||||
| 'revenue_breakdown'
|
||||
| 'lead_source_attribution';
|
||||
export type MetricBase = 'pipeline_funnel' | 'occupancy_timeline' | 'lead_source_attribution';
|
||||
|
||||
/**
|
||||
* Snapshot key. Only preset ranges are cached - custom ranges have an
|
||||
@@ -41,7 +36,6 @@ export type MetricId = `${MetricBase}.${PresetDateRange}`;
|
||||
export const ALL_METRICS: readonly MetricBase[] = [
|
||||
'pipeline_funnel',
|
||||
'occupancy_timeline',
|
||||
'revenue_breakdown',
|
||||
'lead_source_attribution',
|
||||
] as const;
|
||||
|
||||
@@ -61,19 +55,11 @@ export interface OccupancyTimelineData {
|
||||
points: Array<{ date: string; occupied: number; total: number; occupancyPct: number }>;
|
||||
}
|
||||
|
||||
export interface RevenueBreakdownData {
|
||||
bars: Array<{ status: string; amount: number; currency: string }>;
|
||||
}
|
||||
|
||||
export interface LeadSourceAttributionData {
|
||||
slices: Array<{ source: string; count: number }>;
|
||||
}
|
||||
|
||||
export type SnapshotData =
|
||||
| PipelineFunnelData
|
||||
| OccupancyTimelineData
|
||||
| RevenueBreakdownData
|
||||
| LeadSourceAttributionData;
|
||||
export type SnapshotData = PipelineFunnelData | OccupancyTimelineData | LeadSourceAttributionData;
|
||||
|
||||
// ─── Cache layer ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -219,36 +205,6 @@ export async function computeOccupancyTimeline(
|
||||
return { points };
|
||||
}
|
||||
|
||||
export async function computeRevenueBreakdown(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
): Promise<RevenueBreakdownData> {
|
||||
const { from, to } = rangeToBounds(range);
|
||||
const rows = await db
|
||||
.select({
|
||||
status: invoices.status,
|
||||
currency: invoices.currency,
|
||||
amount: sql<string>`coalesce(sum(${invoices.total}), 0)::text`,
|
||||
})
|
||||
.from(invoices)
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.portId, portId),
|
||||
isNull(invoices.archivedAt),
|
||||
between(invoices.createdAt, from, to),
|
||||
),
|
||||
)
|
||||
.groupBy(invoices.status, invoices.currency);
|
||||
|
||||
return {
|
||||
bars: rows.map((r) => ({
|
||||
status: r.status,
|
||||
currency: r.currency,
|
||||
amount: Number(r.amount),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function computeLeadSourceAttribution(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
@@ -307,19 +263,6 @@ export async function getOccupancyTimeline(
|
||||
return fresh;
|
||||
}
|
||||
|
||||
export async function getRevenueBreakdown(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
): Promise<RevenueBreakdownData> {
|
||||
if (isCustomRange(range)) return computeRevenueBreakdown(portId, range);
|
||||
const metricId = `revenue_breakdown.${range}` as const;
|
||||
const cached = await readSnapshot<RevenueBreakdownData>(portId, metricId);
|
||||
if (cached) return cached;
|
||||
const fresh = await computeRevenueBreakdown(portId, range);
|
||||
await writeSnapshot(portId, metricId, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
export async function getLeadSourceAttribution(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
@@ -337,16 +280,14 @@ export async function getLeadSourceAttribution(
|
||||
|
||||
export async function refreshSnapshotsForPort(portId: string): Promise<void> {
|
||||
for (const range of ALL_RANGES) {
|
||||
const [funnel, occupancy, revenue, leadSource] = await Promise.all([
|
||||
const [funnel, occupancy, leadSource] = await Promise.all([
|
||||
computePipelineFunnel(portId, range),
|
||||
computeOccupancyTimeline(portId, range),
|
||||
computeRevenueBreakdown(portId, range),
|
||||
computeLeadSourceAttribution(portId, range),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeSnapshot(portId, `pipeline_funnel.${range}`, funnel),
|
||||
writeSnapshot(portId, `occupancy_timeline.${range}`, occupancy),
|
||||
writeSnapshot(portId, `revenue_breakdown.${range}`, revenue),
|
||||
writeSnapshot(portId, `lead_source_attribution.${range}`, leadSource),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -165,8 +165,15 @@ export async function getRevenueForecast(portId: string) {
|
||||
.innerJoin(berths, eq(interestBerths.berthId, berths.id))
|
||||
.where(activeInterestsWhere(portId));
|
||||
|
||||
// Build stageBreakdown
|
||||
const stageMap: Record<string, { count: number; weightedValue: number }> = {};
|
||||
// Build stageBreakdown — gross value, weighted value, per-stage weight,
|
||||
// and `dealsMissingPrice` (deals whose primary berth has no/zero price)
|
||||
// all surface to callers. The dashboard tile shows a warning chip when
|
||||
// any deals in a stage are missing a berth price so the $0 line item
|
||||
// doesn't read as legitimate.
|
||||
const stageMap: Record<
|
||||
string,
|
||||
{ count: number; grossValue: number; weightedValue: number; dealsMissingPrice: number }
|
||||
> = {};
|
||||
|
||||
for (const row of interestRows) {
|
||||
const stage = row.pipelineStage ?? 'open';
|
||||
@@ -175,21 +182,28 @@ export async function getRevenueForecast(portId: string) {
|
||||
const weighted = price * weight;
|
||||
|
||||
if (!stageMap[stage]) {
|
||||
stageMap[stage] = { count: 0, weightedValue: 0 };
|
||||
stageMap[stage] = { count: 0, grossValue: 0, weightedValue: 0, dealsMissingPrice: 0 };
|
||||
}
|
||||
stageMap[stage]!.count += 1;
|
||||
stageMap[stage]!.grossValue += price;
|
||||
stageMap[stage]!.weightedValue += weighted;
|
||||
if (!(price > 0)) stageMap[stage]!.dealsMissingPrice += 1;
|
||||
}
|
||||
|
||||
const stageBreakdown = PIPELINE_STAGES.map((stage) => ({
|
||||
stage,
|
||||
count: stageMap[stage]?.count ?? 0,
|
||||
grossValue: stageMap[stage]?.grossValue ?? 0,
|
||||
weightedValue: stageMap[stage]?.weightedValue ?? 0,
|
||||
weight: weights[stage] ?? 0,
|
||||
dealsMissingPrice: stageMap[stage]?.dealsMissingPrice ?? 0,
|
||||
}));
|
||||
|
||||
const totalGrossValue = stageBreakdown.reduce((acc, s) => acc + s.grossValue, 0);
|
||||
const totalWeightedValue = stageBreakdown.reduce((acc, s) => acc + s.weightedValue, 0);
|
||||
|
||||
return {
|
||||
totalGrossValue,
|
||||
totalWeightedValue,
|
||||
stageBreakdown,
|
||||
weightsSource,
|
||||
|
||||
@@ -46,8 +46,11 @@ import {
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import type { DocumentSend } from '@/lib/db/schema';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { env } from '@/lib/env';
|
||||
import { injectTrackingPixel } from '@/lib/email/tracking-pixel';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { checkRateLimit } from '@/lib/rate-limit';
|
||||
import { getSetting } from '@/lib/services/settings.service';
|
||||
import { getStorageBackend } from '@/lib/storage';
|
||||
import {
|
||||
EMAIL_BODY_MAX_BYTES,
|
||||
@@ -433,10 +436,24 @@ async function performSend(args: {
|
||||
}): Promise<SendResult> {
|
||||
// 1. Build attachment vs link preamble.
|
||||
const delivery = await streamAttachmentOrLink(args.portId, args.attachment);
|
||||
const finalHtml = delivery.bodySuffixHtml
|
||||
let finalHtml = delivery.bodySuffixHtml
|
||||
? `${args.bodyHtml}\n${delivery.bodySuffixHtml}`
|
||||
: args.bodyHtml;
|
||||
|
||||
// 1b. Phase 4b — open tracking. Pre-allocate the send-row UUID so we
|
||||
// can embed a per-send tracking pixel before we know whether the SMTP
|
||||
// call will succeed. The pixel endpoint itself gates on
|
||||
// `track_opens=true`, so a failed send with the pixel still embedded
|
||||
// is a harmless no-op even if a recipient somehow opens the partial.
|
||||
const trackOpens = await isOpenTrackingEnabled(args.portId);
|
||||
const preallocatedId = trackOpens ? crypto.randomUUID() : undefined;
|
||||
if (trackOpens && preallocatedId && env.NEXT_PUBLIC_APP_URL) {
|
||||
finalHtml = injectTrackingPixel(finalHtml, {
|
||||
appBaseUrl: env.NEXT_PUBLIC_APP_URL,
|
||||
sendId: preallocatedId,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Create the transporter (per-port sales account).
|
||||
let transporter, fromAddress;
|
||||
try {
|
||||
@@ -447,6 +464,7 @@ async function performSend(args: {
|
||||
.insert(documentSends)
|
||||
.values({
|
||||
...args.recordSeed,
|
||||
...(preallocatedId ? { id: preallocatedId, trackOpens: true } : {}),
|
||||
fromAddress: args.recordSeed.fromAddress || 'unknown',
|
||||
bodyMarkdown: args.recordSeed.bodyMarkdown ?? null,
|
||||
failedAt: new Date(),
|
||||
@@ -473,11 +491,21 @@ async function performSend(args: {
|
||||
.insert(documentSends)
|
||||
.values({
|
||||
...args.recordSeed,
|
||||
...(preallocatedId ? { id: preallocatedId, trackOpens: true } : {}),
|
||||
fromAddress,
|
||||
messageId: info.messageId ?? null,
|
||||
fallbackToLinkReason: delivery.deliveredAsAttachment ? null : 'size_above_threshold',
|
||||
})
|
||||
.returning();
|
||||
// Phase 7 — Umami attribution. Send completion is the "email sent"
|
||||
// half of the email funnel; opens (Phase 4b) and click-throughs
|
||||
// (Phase 4c) follow as separate events keyed by sendId.
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
trackEvent(args.portId, 'email-sent', {
|
||||
sendId: row!.id,
|
||||
documentKind: row!.documentKind,
|
||||
}),
|
||||
);
|
||||
return { send: row!, deliveredAsAttachment: delivery.deliveredAsAttachment };
|
||||
} catch (sendErr) {
|
||||
const msg = sendErr instanceof Error ? sendErr.message : String(sendErr);
|
||||
@@ -486,6 +514,7 @@ async function performSend(args: {
|
||||
.insert(documentSends)
|
||||
.values({
|
||||
...args.recordSeed,
|
||||
...(preallocatedId ? { id: preallocatedId, trackOpens: true } : {}),
|
||||
fromAddress,
|
||||
failedAt: new Date(),
|
||||
errorReason: msg,
|
||||
@@ -686,3 +715,24 @@ export async function listSends(filters: ListSendsFilters): Promise<DocumentSend
|
||||
.limit(filters.limit ?? 100);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Phase 4b — per-port kill switch for email open tracking. Stored in
|
||||
// `system_settings` under `email_open_tracking_enabled` (boolean). Default
|
||||
// FALSE so the feature is explicit-opt-in by an admin. Cached per-port
|
||||
// for 60 s to avoid hitting `system_settings` on every send.
|
||||
const trackingEnabledCache = new Map<string, { value: boolean; expiresAt: number }>();
|
||||
const TRACKING_TTL_MS = 60_000;
|
||||
|
||||
async function isOpenTrackingEnabled(portId: string): Promise<boolean> {
|
||||
const cached = trackingEnabledCache.get(portId);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
const row = await getSetting('email_open_tracking_enabled', portId);
|
||||
// value is stored as a JSON-encoded primitive — accept boolean true OR
|
||||
// the strings "true" / "1" for resilience against admin UIs that
|
||||
// serialize booleans as strings.
|
||||
const raw = row?.value as unknown;
|
||||
const enabled =
|
||||
raw === true || raw === 1 || (typeof raw === 'string' && (raw === 'true' || raw === '1'));
|
||||
trackingEnabledCache.set(portId, { value: enabled, expiresAt: Date.now() + TRACKING_TTL_MS });
|
||||
return enabled;
|
||||
}
|
||||
|
||||
@@ -1618,6 +1618,16 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
'EOI signed via Documenso',
|
||||
'eoi_signed',
|
||||
);
|
||||
|
||||
// Phase 7 — Umami attribution. EOI signed is the headline
|
||||
// conversion event so it gets its own Umami event for funnel
|
||||
// visibility (rather than rolling up into "interest-stage-changed").
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
trackEvent(doc.portId, 'eoi-signed', {
|
||||
interestId: doc.interestId,
|
||||
documentId: doc.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -777,6 +777,16 @@ export async function createInterest(portId: string, data: CreateInterestInput,
|
||||
}),
|
||||
);
|
||||
|
||||
// Phase 6 — CRM → Umami attribution. Fire an inbound-lead event so
|
||||
// marketing can correlate inquiry volume with website traffic by
|
||||
// source / referrer.
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
trackEvent(portId, 'interest-created', {
|
||||
interestId: result.id,
|
||||
source: result.source ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1016,6 +1026,15 @@ export async function changeInterestStage(
|
||||
}),
|
||||
);
|
||||
|
||||
// Phase 6 — CRM → Umami attribution for pipeline movement.
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
trackEvent(portId, 'interest-stage-changed', {
|
||||
interestId: id,
|
||||
oldStage: oldStage ?? null,
|
||||
newStage: data.pipelineStage,
|
||||
}),
|
||||
);
|
||||
|
||||
// Fire-and-forget notification to the acting user. Resolve a friendly
|
||||
// label (client full name → primary mooring number → "this interest") so
|
||||
// the inbox doesn't surface a raw UUID; stage names go through the
|
||||
@@ -1216,6 +1235,18 @@ export async function setInterestOutcome(
|
||||
// via system_settings.berth_rules.
|
||||
void evaluateRule('interest_completed', id, portId, meta);
|
||||
|
||||
// Phase 6 — CRM → Umami attribution. Fire a custom Umami event so
|
||||
// marketing can correlate inbound website traffic with the resulting
|
||||
// deal outcome. Dynamic import to avoid a circular service dep at
|
||||
// module-load time.
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
trackEvent(portId, 'interest-outcome-set', {
|
||||
interestId: id,
|
||||
outcome: data.outcome,
|
||||
stageAtOutcome,
|
||||
}),
|
||||
);
|
||||
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,14 @@ export interface QualificationRow {
|
||||
* they just have to know the berth size they want.
|
||||
*/
|
||||
autoSatisfied: boolean;
|
||||
/**
|
||||
* Human-readable summary of WHY a criterion is auto-satisfied (e.g.
|
||||
* "Desired: 60 × 25 × 6 ft"). Empty string when the criterion is not
|
||||
* auto-satisfied OR when no derivation rule applies. Surfaced on the
|
||||
* checklist row so the rep can see the evidence behind the tick — the
|
||||
* "why is this checked?" question came up in UAT.
|
||||
*/
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,6 +304,16 @@ export async function listInterestQualifications(
|
||||
},
|
||||
});
|
||||
const explicit = s?.confirmed ?? false;
|
||||
const evidence = autoSatisfied
|
||||
? computeEvidence(c.key, {
|
||||
yachtDims,
|
||||
desiredDims: {
|
||||
lengthFt: interest.desiredLengthFt ?? null,
|
||||
widthFt: interest.desiredWidthFt ?? null,
|
||||
draftFt: interest.desiredDraftFt ?? null,
|
||||
},
|
||||
})
|
||||
: '';
|
||||
return {
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
@@ -311,6 +329,7 @@ export async function listInterestQualifications(
|
||||
confirmedBy: s?.confirmedBy ?? null,
|
||||
notes: s?.notes ?? null,
|
||||
autoSatisfied,
|
||||
evidence,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -340,6 +359,37 @@ function computeAutoSatisfied(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short human-readable string explaining what data drove the
|
||||
* auto-satisfaction. Mirrors `computeAutoSatisfied`'s branching so the UI
|
||||
* can render "Auto · <evidence>" — closes the "why is this ticked?" gap.
|
||||
*/
|
||||
function computeEvidence(
|
||||
key: string,
|
||||
ctx: {
|
||||
yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null } | null;
|
||||
desiredDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null };
|
||||
},
|
||||
): string {
|
||||
if (key === 'dimensions') {
|
||||
const hasYacht =
|
||||
!!ctx.yachtDims &&
|
||||
!!ctx.yachtDims.lengthFt &&
|
||||
!!ctx.yachtDims.widthFt &&
|
||||
!!ctx.yachtDims.draftFt;
|
||||
if (hasYacht && ctx.yachtDims) {
|
||||
return `Yacht: ${ctx.yachtDims.lengthFt} × ${ctx.yachtDims.widthFt} × ${ctx.yachtDims.draftFt} ft`;
|
||||
}
|
||||
const hasDesired =
|
||||
!!ctx.desiredDims.lengthFt && !!ctx.desiredDims.widthFt && !!ctx.desiredDims.draftFt;
|
||||
if (hasDesired) {
|
||||
return `Desired: ${ctx.desiredDims.lengthFt} × ${ctx.desiredDims.widthFt} × ${ctx.desiredDims.draftFt} ft`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a single criterion's confirmed-state for an interest. Stamping the
|
||||
* server-side fields (confirmedBy / confirmedAt) makes the row a proper
|
||||
|
||||
Reference in New Issue
Block a user