Compare commits
4 Commits
93c6554c95
...
39c19b2340
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c19b2340 | |||
| d1f6d6a427 | |||
| 3b227fe9b2 | |||
| 95724c8e3a |
@@ -80,6 +80,7 @@
|
||||
"country-flag-icons": "^1.6.17",
|
||||
"cron-parser": "^5.5.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx-preview": "^0.3.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -157,6 +157,9 @@ importers:
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
docx-preview:
|
||||
specifier: ^0.3.7
|
||||
version: 0.3.7
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.2
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9)
|
||||
@@ -4226,6 +4229,9 @@ packages:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
docx-preview@0.3.7:
|
||||
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
@@ -11146,6 +11152,10 @@ snapshots:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
|
||||
docx-preview@0.3.7:
|
||||
dependencies:
|
||||
jszip: 3.10.1
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
21
src/app/api/v1/documents/[id]/send-signed-copy/route.ts
Normal file
21
src/app/api/v1/documents/[id]/send-signed-copy/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { sendSignedCopyToClient } from '@/lib/services/documents.service';
|
||||
|
||||
/**
|
||||
* Manually (re)send the finalized signed PDF to the deal's client. Backs
|
||||
* the "Send signed copy to client" affordance on the EOI tab + document
|
||||
* detail. Same `documents.edit` gate as the reminder endpoint.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('documents', 'edit', async (_req, ctx, params) => {
|
||||
try {
|
||||
const result = await sendSignedCopyToClient(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
import { BerthStatusQuickEdit } from './berth-status-quick-edit';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
import type { BerthRow } from './berth-columns';
|
||||
import { mooringLetterDot } from './mooring-letter-tone';
|
||||
@@ -167,7 +168,9 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
|
||||
{/* Status pill + tags */}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<StatusPill status={statusPill}>{statusLabel}</StatusPill>
|
||||
<BerthStatusQuickEdit berthId={berth.id} currentStatus={berth.status}>
|
||||
<StatusPill status={statusPill}>{statusLabel}</StatusPill>
|
||||
</BerthStatusQuickEdit>
|
||||
{tags.slice(0, 2).map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
import { BerthStatusQuickEdit } from './berth-status-quick-edit';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
@@ -342,7 +343,9 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
const isManualUnreconciled = isManual && !r.latestInterestStage;
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<StatusBadge status={r.status} />
|
||||
<BerthStatusQuickEdit berthId={r.id} currentStatus={r.status}>
|
||||
<StatusBadge status={r.status} />
|
||||
</BerthStatusQuickEdit>
|
||||
{isManual ? <ManualBadge variant={isManualUnreconciled ? 'catchup' : 'pinned'} /> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -187,36 +187,42 @@ export function BerthList() {
|
||||
applyView({ filters: savedFilters, sort: savedSort });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDensity(density === 'compact' ? 'comfortable' : 'compact')}
|
||||
aria-label={
|
||||
density === 'compact'
|
||||
? 'Switch to comfortable row spacing'
|
||||
: 'Switch to compact row spacing'
|
||||
}
|
||||
title={density === 'compact' ? 'Comfortable rows' : 'Compact rows'}
|
||||
>
|
||||
{density === 'compact' ? (
|
||||
<Rows3 className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Rows4 className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDimensionUnit(dimensionUnit === 'ft' ? 'm' : 'ft')}
|
||||
aria-label={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
|
||||
title={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{dimensionUnit === 'ft' ? 'ft' : 'm'}
|
||||
</Button>
|
||||
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
{/* Table-only controls — hidden in card mode (<lg, matching
|
||||
DataTable's table/card switch). The BerthCard ignores row
|
||||
density + dimension unit and renders no column set, so these
|
||||
toggles have no visible effect there and read as broken. */}
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDensity(density === 'compact' ? 'comfortable' : 'compact')}
|
||||
aria-label={
|
||||
density === 'compact'
|
||||
? 'Switch to comfortable row spacing'
|
||||
: 'Switch to compact row spacing'
|
||||
}
|
||||
title={density === 'compact' ? 'Comfortable rows' : 'Compact rows'}
|
||||
>
|
||||
{density === 'compact' ? (
|
||||
<Rows3 className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Rows4 className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDimensionUnit(dimensionUnit === 'ft' ? 'm' : 'ft')}
|
||||
aria-label={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
|
||||
title={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{dimensionUnit === 'ft' ? 'ft' : 'm'}
|
||||
</Button>
|
||||
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
</div>
|
||||
<ExportListPdfButton kind="berths" />
|
||||
{canBulkAdd && (
|
||||
<Button asChild size="sm" variant="default">
|
||||
|
||||
242
src/components/berths/berth-status-quick-edit.tsx
Normal file
242
src/components/berths/berth-status-quick-edit.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { useVocabulary } from '@/hooks/use-vocabulary';
|
||||
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
|
||||
import { BERTH_STATUSES, stageLabel } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
available: 'Available',
|
||||
under_offer: 'Under Offer',
|
||||
sold: 'Sold',
|
||||
};
|
||||
|
||||
interface InterestOption {
|
||||
id: string;
|
||||
clientName: string;
|
||||
pipelineStage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-to-change berth status from the berths LIST. Wraps the status chip
|
||||
* (passed as children) in a button that opens a compact change-status dialog
|
||||
* — status dropdown + required reason (with quick-pick chips) + an optional
|
||||
* interest link when moving to under_offer/sold. Same PATCH endpoint +
|
||||
* validator + audit as the berth detail page. Reps without `berths.edit` see
|
||||
* a plain, non-interactive chip via the PermissionGate fallback.
|
||||
*/
|
||||
export function BerthStatusQuickEdit({
|
||||
berthId,
|
||||
currentStatus,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
berthId: string;
|
||||
currentStatus: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<PermissionGate resource="berths" action="edit" fallback={<>{children}</>}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
// The chip usually sits inside a clickable list card/row — stop the
|
||||
// click from also navigating to the berth detail page.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
title="Change status"
|
||||
aria-label="Change berth status"
|
||||
className={cn(
|
||||
'cursor-pointer rounded-full outline-none transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{open && (
|
||||
<BerthStatusQuickEditDialog
|
||||
berthId={berthId}
|
||||
currentStatus={currentStatus}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
)}
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
|
||||
function BerthStatusQuickEditDialog({
|
||||
berthId,
|
||||
currentStatus,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
berthId: string;
|
||||
currentStatus: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const reasonChips = useVocabulary('berth_status_change_reasons');
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UpdateBerthStatusInput>({
|
||||
resolver: zodResolver(updateBerthStatusSchema),
|
||||
defaultValues: { status: currentStatus as (typeof BERTH_STATUSES)[number], reason: '' },
|
||||
});
|
||||
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
|
||||
|
||||
const status = watch('status');
|
||||
const interestId = watch('interestId');
|
||||
const showInterestPicker = status === 'under_offer' || status === 'sold';
|
||||
|
||||
// Active interests for the picker — only fetched once the picker is shown.
|
||||
const interestsQuery = useQuery<{ data: InterestOption[] }>({
|
||||
queryKey: ['interests', 'status-link-picker'],
|
||||
queryFn: () => apiFetch('/api/v1/interests?pageSize=200&sort=updatedAt&order=desc'),
|
||||
enabled: open && showInterestPicker,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const interestOptions = interestsQuery.data?.data ?? [];
|
||||
|
||||
async function onSubmit(data: UpdateBerthStatusInput) {
|
||||
try {
|
||||
await apiFetch(`/api/v1/berths/${berthId}/status`, { method: 'PATCH', body: data });
|
||||
queryClient.invalidateQueries({ queryKey: ['berths'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['berth', berthId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
toast.success('Status updated');
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change status</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4">
|
||||
<FormErrorSummary
|
||||
errors={errors}
|
||||
labels={{ status: 'Status', reason: 'Reason', interestId: 'Linked interest' }}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label>New status</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(v) => {
|
||||
setValue('status', v as (typeof BERTH_STATUSES)[number]);
|
||||
// Clear any stale interest pick when returning to available.
|
||||
if (v === 'available') setValue('interestId', undefined);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BERTH_STATUSES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STATUS_LABELS[s] ?? s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Reason *</Label>
|
||||
{reasonChips.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{reasonChips.map((chip) => (
|
||||
<button
|
||||
type="button"
|
||||
key={chip}
|
||||
onClick={() => setValue('reason', chip, { shouldDirty: true })}
|
||||
className="rounded-full border border-muted-foreground/20 bg-muted px-2.5 py-0.5 text-xs hover:bg-accent"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Textarea {...register('reason')} placeholder="Reason for status change…" rows={3} />
|
||||
</div>
|
||||
{showInterestPicker && (
|
||||
<div className="space-y-2">
|
||||
<Label>Linked interest (optional)</Label>
|
||||
<Select
|
||||
value={interestId ?? '__none__'}
|
||||
onValueChange={(v) => setValue('interestId', v === '__none__' ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No interest" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No interest</SelectItem>
|
||||
{interestOptions.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{`${o.clientName || '(unnamed)'} · ${stageLabel(o.pipelineStage)}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Links this change to the interest it relates to — it shows on that interest's
|
||||
timeline and the berth attaches to it automatically.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving…' : 'Update status'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -54,11 +54,22 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
|
||||
|
||||
const interest = client.latestInterest ?? null;
|
||||
const interestCount = client.interestCount ?? 0;
|
||||
const interestBerthLabel = interest
|
||||
? interest.mooringNumber
|
||||
? `Berth ${interest.mooringNumber}`
|
||||
: 'General interest'
|
||||
: null;
|
||||
// Show ALL berths the client has interests in (across every interest),
|
||||
// not just the latest interest's primary mooring — matches the desktop
|
||||
// table's Berths column + the interest header. Cap the inline list so
|
||||
// the card stays compact; overflow folds into a "+N" suffix.
|
||||
const linkedBerths = client.linkedBerths ?? [];
|
||||
const MAX_BERTHS_SHOWN = 4;
|
||||
const shownMoorings = linkedBerths.slice(0, MAX_BERTHS_SHOWN).map((b) => b.mooringNumber);
|
||||
const extraBerths = linkedBerths.length - shownMoorings.length;
|
||||
const interestBerthLabel =
|
||||
shownMoorings.length > 0
|
||||
? `${linkedBerths.length === 1 ? 'Berth' : 'Berths'} ${shownMoorings.join(', ')}${
|
||||
extraBerths > 0 ? ` +${extraBerths}` : ''
|
||||
}`
|
||||
: interest
|
||||
? 'General interest'
|
||||
: null;
|
||||
const interestStageLabel = interest ? stageLabel(interest.stage) : null;
|
||||
const interestStageBadge = interest ? stageBadgeClass(interest.stage) : null;
|
||||
const extraInterests = interestCount > 1 ? interestCount - 1 : 0;
|
||||
|
||||
87
src/components/files/docx-viewer.tsx
Normal file
87
src/components/files/docx-viewer.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* In-app .docx viewer.
|
||||
*
|
||||
* Renders Word OOXML (.docx) client-side via `docx-preview` (lazy-loaded
|
||||
* so the ~library cost only lands on routes that actually preview a docx).
|
||||
* We fetch the bytes from our own storage URL and render them in-browser —
|
||||
* deliberately NOT delegating to Microsoft's hosted Office viewer, which
|
||||
* requires a publicly-reachable URL and so can't render documents stored
|
||||
* in our private object store.
|
||||
*
|
||||
* Legacy .doc / .xls / .xlsx are not handled here (docx-preview is OOXML-
|
||||
* Word only); the preview dialog routes those to a download CTA instead.
|
||||
*/
|
||||
export function DocxViewer({ url, fileName }: { url: string; fileName?: string }) {
|
||||
// Key-based remount on url change keeps render state (loading/error +
|
||||
// the imperatively-populated container) re-initialised from scratch,
|
||||
// mirroring PdfViewer.
|
||||
return <DocxViewerBody key={url} url={url} fileName={fileName} />;
|
||||
}
|
||||
|
||||
function DocxViewerBody({ url, fileName }: { url: string; fileName?: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function render() {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to load document (${res.status})`);
|
||||
const blob = await res.blob();
|
||||
if (cancelled) return;
|
||||
const { renderAsync } = await import('docx-preview');
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
await renderAsync(blob, container, undefined, {
|
||||
className: 'docx',
|
||||
inWrapper: true,
|
||||
// Let the document flow to the container width rather than
|
||||
// forcing fixed A4 page metrics that overflow the dialog.
|
||||
ignoreWidth: true,
|
||||
ignoreHeight: true,
|
||||
breakPages: true,
|
||||
});
|
||||
if (!cancelled) setError(null);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to render document');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
void render();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-auto bg-muted/30 p-4">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
Rendering document…
|
||||
</div>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
aria-label={fileName ?? 'Document preview'}
|
||||
className="mx-auto max-w-3xl [&_.docx-wrapper]:bg-transparent [&_.docx-wrapper]:p-0 [&_.docx-wrapper>section.docx]:mx-auto [&_.docx-wrapper>section.docx]:mb-4 [&_.docx-wrapper>section.docx]:bg-white [&_.docx-wrapper>section.docx]:shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { isWordDocx } from '@/lib/constants/file-validation';
|
||||
|
||||
// yet-another-react-lightbox is ~50kb, lazy-load it.
|
||||
const Lightbox = dynamic(() => import('yet-another-react-lightbox'), { ssr: false });
|
||||
@@ -30,6 +31,16 @@ const PdfViewer = dynamic(() => import('./pdf-viewer').then((m) => ({ default: m
|
||||
),
|
||||
});
|
||||
|
||||
// docx-preview is lazy-loaded the same way — only .docx previews pull it in.
|
||||
const DocxViewer = dynamic(() => import('./docx-viewer').then((m) => ({ default: m.DocxViewer })), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading document viewer…
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface FilePreviewDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -185,24 +196,34 @@ export function FilePreviewDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && previewUrl && kind === 'office' && (
|
||||
// Office documents render via Microsoft's hosted Office viewer
|
||||
// - public URL only; presigned download URLs include a token
|
||||
// in the query string so they work here even though the file
|
||||
// isn't world-public. The viewer streams the document and
|
||||
// renders a high-fidelity preview without us shipping a
|
||||
// headless LibreOffice. Falls back to "download to view" if
|
||||
// the embed loads but renders nothing (e.g. CORS rejected) -
|
||||
// detection is hard so we just keep the download CTA below.
|
||||
<iframe
|
||||
title={fileName ?? 'Office document preview'}
|
||||
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(
|
||||
previewUrl,
|
||||
)}`}
|
||||
className="h-full w-full"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||
/>
|
||||
)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
previewUrl &&
|
||||
kind === 'office' &&
|
||||
// Word .docx renders in-browser via docx-preview (fetches the
|
||||
// bytes from our own storage — works with private MinIO/disk).
|
||||
// We do NOT use Microsoft's hosted Office viewer: it requires a
|
||||
// publicly-reachable URL, which our private object store isn't.
|
||||
// Legacy .doc + spreadsheet formats can't be rendered client-
|
||||
// side, so they fall through to a download CTA.
|
||||
(isWordDocx(mimeType, fileName) ? (
|
||||
<DocxViewer url={previewUrl} fileName={fileName} />
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<FileWarning className="size-8 text-muted-foreground" aria-hidden />
|
||||
<p className="text-sm font-medium">In-browser preview isn't available</p>
|
||||
<p className="max-w-xs text-xs text-muted-foreground">
|
||||
This Office format ({mimeType ?? 'unknown'}) can't be rendered in the
|
||||
browser. Download it to view locally.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a href={previewUrl} download={fileName ?? 'download'}>
|
||||
<Download className="mr-1.5 size-4" aria-hidden />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && !error && previewUrl && kind === 'unknown' && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
FileSignature,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
XCircle,
|
||||
@@ -122,6 +123,32 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
// (which the storage backend serves with Content-Disposition=attachment,
|
||||
// forcing a download even when the rep just wants to inspect the PDF).
|
||||
const [previewFile, setPreviewFile] = useState<{ id: string; name?: string } | null>(null);
|
||||
const { confirm, dialog: confirmDialog } = useConfirmation();
|
||||
|
||||
// Manually (re)send the finalized signed PDF to the deal's client.
|
||||
// Lifted to the parent (like the preview dialog) so every row + the
|
||||
// signed-EOI hero share one confirm + handler. Guarded by a confirm so
|
||||
// a stray click can't fire a real client email.
|
||||
const handleSendCopy = useCallback(
|
||||
async (documentId: string) => {
|
||||
const ok = await confirm({
|
||||
title: 'Send signed copy to client?',
|
||||
description: 'Emails the deal’s client the finalized signed PDF as an attachment.',
|
||||
confirmLabel: 'Send copy',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await apiFetch<{ data: { recipientEmail: string } }>(
|
||||
`/api/v1/documents/${documentId}/send-signed-copy`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
toast.success(`Signed copy sent to ${res.data.recipientEmail}.`);
|
||||
} catch (err) {
|
||||
toastError(err, 'Failed to send signed copy');
|
||||
}
|
||||
},
|
||||
[confirm],
|
||||
);
|
||||
|
||||
const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({
|
||||
queryKey: ['documents', { interestId, documentType: 'eoi' }],
|
||||
@@ -134,6 +161,22 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
const docs = docsRes?.data ?? [];
|
||||
const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]);
|
||||
const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]);
|
||||
// Most-recent fully-signed EOI. When no EOI is in flight, this becomes
|
||||
// the hero (instead of the generate/upload empty state) so a deal whose
|
||||
// EOI is already done leads with the signed document, per UAT 2026-06-03.
|
||||
const latestSignedDoc = useMemo(() => {
|
||||
return (
|
||||
docs
|
||||
.filter((d) => d.status === 'completed')
|
||||
.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt))[0] ?? null
|
||||
);
|
||||
}, [docs]);
|
||||
// History strip excludes whichever signed doc is shown as the hero so it
|
||||
// isn't listed twice.
|
||||
const historyDocs = useMemo(
|
||||
() => completedDocs.filter((d) => d.id !== latestSignedDoc?.id),
|
||||
[completedDocs, latestSignedDoc],
|
||||
);
|
||||
|
||||
// Pulled at the parent so we can thread the active EOI's signers into the
|
||||
// ExternalEoiUploadDialog as a prefill seed. ActiveEoiCard hits the same
|
||||
@@ -176,6 +219,15 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
portSlug={portSlug ?? null}
|
||||
onUploadSigned={() => setUploadSignedOpen(true)}
|
||||
onView={(id, name) => setPreviewFile({ id, name })}
|
||||
onSendCopy={handleSendCopy}
|
||||
/>
|
||||
) : latestSignedDoc ? (
|
||||
<SignedEoiCard
|
||||
doc={latestSignedDoc}
|
||||
portSlug={portSlug ?? null}
|
||||
onView={(id, name) => setPreviewFile({ id, name })}
|
||||
onSendCopy={handleSendCopy}
|
||||
onGenerateNew={() => setGenerateOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<EmptyEoiState
|
||||
@@ -189,18 +241,18 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
{/* History strip - completed + cancelled EOIs from earlier in the
|
||||
deal's life. Quiet and skimmable; the active document above
|
||||
carries the day-to-day attention. */}
|
||||
{completedDocs.length > 0 && (
|
||||
{historyDocs.length > 0 && (
|
||||
<section className="rounded-lg border bg-background">
|
||||
<header className="flex items-center justify-between border-b px-4 py-2.5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
EOI history
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
|
||||
{historyDocs.length} {historyDocs.length === 1 ? 'document' : 'documents'}
|
||||
</span>
|
||||
</header>
|
||||
<ul className="divide-y">
|
||||
{completedDocs.map((d) => (
|
||||
{historyDocs.map((d) => (
|
||||
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||||
<StatusBadge status={d.status} />
|
||||
<span className="flex-1 truncate font-medium">{d.title}</span>
|
||||
@@ -210,8 +262,11 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
{d.signedFileId ? (
|
||||
<SignedPdfActions
|
||||
fileId={d.signedFileId}
|
||||
documentId={d.id}
|
||||
isSignedCopySendable={d.status === 'completed'}
|
||||
title={d.title}
|
||||
onView={(id, name) => setPreviewFile({ id, name })}
|
||||
onSendCopy={handleSendCopy}
|
||||
/>
|
||||
) : null}
|
||||
{portSlug && (
|
||||
@@ -272,6 +327,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
fileId={previewFile?.id}
|
||||
fileName={previewFile?.name}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -283,11 +339,13 @@ function ActiveEoiCard({
|
||||
portSlug,
|
||||
onUploadSigned,
|
||||
onView,
|
||||
onSendCopy,
|
||||
}: {
|
||||
doc: DocumentRow;
|
||||
portSlug: string | null;
|
||||
onUploadSigned: () => void;
|
||||
onView: (fileId: string, fileName?: string) => void;
|
||||
onSendCopy: (documentId: string) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmation();
|
||||
@@ -614,7 +672,14 @@ function ActiveEoiCard({
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Signed document
|
||||
</h3>
|
||||
<SignedPdfActions fileId={doc.signedFileId} title={doc.title} onView={onView} />
|
||||
<SignedPdfActions
|
||||
fileId={doc.signedFileId}
|
||||
documentId={doc.id}
|
||||
isSignedCopySendable={doc.status === 'completed'}
|
||||
title={doc.title}
|
||||
onView={onView}
|
||||
onSendCopy={onSendCopy}
|
||||
/>
|
||||
</div>
|
||||
<SignedPdfPreview fileId={doc.signedFileId} />
|
||||
</div>
|
||||
@@ -711,6 +776,90 @@ function ActiveEoiCard({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Signed EOI hero (no active EOI, but one is already signed) ───────────────
|
||||
|
||||
/**
|
||||
* Shown when the deal has a fully-signed EOI and nothing is in flight. Leads
|
||||
* with the signed document (preview + download + send-to-client) instead of
|
||||
* the generate/upload empty state — a deal whose EOI is done shouldn't open
|
||||
* on a big "Generate EOI" CTA. A quiet "Generate new EOI" remains for the
|
||||
* re-issue case.
|
||||
*/
|
||||
function SignedEoiCard({
|
||||
doc,
|
||||
portSlug,
|
||||
onView,
|
||||
onSendCopy,
|
||||
onGenerateNew,
|
||||
}: {
|
||||
doc: DocumentRow;
|
||||
portSlug: string | null;
|
||||
onView: (fileId: string, fileName?: string) => void;
|
||||
onSendCopy: (documentId: string) => void;
|
||||
onGenerateNew: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border bg-gradient-brand-soft p-5 shadow-xs">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CheckCircle2 className="size-4 text-emerald-600" aria-hidden />
|
||||
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
|
||||
<StatusBadge status={doc.status} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Signed · {new Date(doc.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
||||
{doc.signedFileId ? (
|
||||
<SignedPdfActions
|
||||
fileId={doc.signedFileId}
|
||||
documentId={doc.id}
|
||||
isSignedCopySendable={doc.status === 'completed'}
|
||||
title={doc.title}
|
||||
onView={onView}
|
||||
onSendCopy={onSendCopy}
|
||||
/>
|
||||
) : null}
|
||||
{portSlug && (
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/documents/${doc.id}` as any}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
Open in Documents
|
||||
<ExternalLink className="size-3" aria-hidden />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{doc.signedFileId ? (
|
||||
<div className="mt-4 rounded-lg border bg-background p-4">
|
||||
<SignedPdfPreview fileId={doc.signedFileId} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 rounded-md border border-dashed bg-background p-3 text-xs text-muted-foreground">
|
||||
The signed PDF isn't linked to this EOI yet, so inline preview, download, and send
|
||||
aren't available. Open it in Documents — this lights up once migrated EOIs are
|
||||
reconciled to their signed files.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<footer className="mt-3 flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This deal's EOI is signed. Generate a new one only if you need to re-issue it.
|
||||
</p>
|
||||
<Button variant="ghost" size="sm" onClick={onGenerateNew} className="gap-1.5">
|
||||
<FileSignature className="size-4" aria-hidden />
|
||||
Generate new EOI
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline iframe preview of a signed PDF. Fetches a short-lived presigned
|
||||
* URL from `/api/v1/files/[id]/download` and renders the browser's native
|
||||
@@ -822,12 +971,21 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
|
||||
*/
|
||||
function SignedPdfActions({
|
||||
fileId,
|
||||
documentId,
|
||||
isSignedCopySendable = false,
|
||||
title,
|
||||
onView,
|
||||
onSendCopy,
|
||||
}: {
|
||||
fileId: string;
|
||||
/** Document id — required for the "Send to client" action (which targets
|
||||
* the document, not the raw file). */
|
||||
documentId?: string;
|
||||
/** Only show "Send to client" for a fully-completed document. */
|
||||
isSignedCopySendable?: boolean;
|
||||
title?: string;
|
||||
onView: (fileId: string, fileName?: string) => void;
|
||||
onSendCopy?: (documentId: string) => void;
|
||||
}) {
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
@@ -855,6 +1013,15 @@ function SignedPdfActions({
|
||||
>
|
||||
<Download className="size-3" aria-hidden /> Download
|
||||
</button>
|
||||
{onSendCopy && documentId && isSignedCopySendable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSendCopy(documentId)}
|
||||
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<Mail className="size-3" aria-hidden /> Send to client
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ export function InterestList() {
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="New interest"
|
||||
className="fixed bottom-[calc(env(safe-area-inset-bottom)+86px)] right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 lg:hidden"
|
||||
className="fixed bottom-[calc(env(safe-area-inset-bottom)+86px)] right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 md:hidden"
|
||||
>
|
||||
<Plus className="h-6 w-6" aria-hidden />
|
||||
</button>
|
||||
|
||||
@@ -402,6 +402,20 @@ function SidebarContent({
|
||||
if (section.requiresResidentialModule && !residentialModuleEnabled) return null;
|
||||
if (section.umamiRequired && !umamiConfigured) return null;
|
||||
|
||||
// Resolve the items this section will actually render after
|
||||
// per-item module/permission gating. If they all gate off
|
||||
// (e.g. the Financial section once the Expenses module is
|
||||
// disabled), skip the whole section so its header + separator
|
||||
// don't linger as an orphaned label.
|
||||
const visibleItems = section.items.filter((item) => {
|
||||
const gated = item as NavItemGated;
|
||||
if (gated.requiresTenanciesModule && !tenanciesModuleEnabled) return false;
|
||||
if (gated.requiresExpensesModule && !expensesModuleEnabled) return false;
|
||||
if (gated.umamiRequired && !umamiConfigured) return false;
|
||||
return true;
|
||||
});
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={section.title}>
|
||||
{!collapsed && (
|
||||
@@ -425,24 +439,15 @@ function SidebarContent({
|
||||
)}
|
||||
{(!section.adminRequired || adminExpanded || collapsed) && (
|
||||
<ul className="space-y-0.5">
|
||||
{section.items
|
||||
.filter((item) => {
|
||||
const gated = item as NavItemGated;
|
||||
if (gated.requiresTenanciesModule && !tenanciesModuleEnabled)
|
||||
return false;
|
||||
if (gated.requiresExpensesModule && !expensesModuleEnabled) return false;
|
||||
if (gated.umamiRequired && !umamiConfigured) return false;
|
||||
return true;
|
||||
})
|
||||
.map((item) => (
|
||||
<li key={item.href}>
|
||||
<NavItemLink
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
active={isActive(item.href, item.exact)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{visibleItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<NavItemLink
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
active={isActive(item.href, item.exact)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Separator className="mt-3 bg-slate-200" aria-hidden />
|
||||
|
||||
@@ -51,7 +51,13 @@ export function Topbar({ ports, user, leadingSlot }: TopbarProps) {
|
||||
// Sarah Doe"). Detail pages register their parent via
|
||||
// `useBreadcrumbHint` so the label is entity-aware; everything else
|
||||
// is URL-derived. See src/hooks/use-smart-back.ts.
|
||||
<header className="relative grid h-14 grid-cols-[auto_1fr_auto] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
|
||||
// `z-30` makes the header a stacking context that sits ABOVE the page
|
||||
// content in <main>. Without it the search dropdown (absolute, z-50 within
|
||||
// the header) still paints under main's charts/tables: header + main are
|
||||
// sibling normal-flow boxes, so main paints later and wins regardless of
|
||||
// the dropdown's own z. Kept below the modal/overlay tier (z-50) so
|
||||
// dialogs still cover the bar.
|
||||
<header className="relative z-30 grid h-14 grid-cols-[auto_1fr_auto] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
|
||||
{/* LEFT: optional sidebar trigger (tablet) + smart back button.
|
||||
Hard-capped width so the column never extends into the
|
||||
absolutely-positioned search bar's footprint. The cap is
|
||||
@@ -62,33 +68,18 @@ export function Topbar({ ports, user, leadingSlot }: TopbarProps) {
|
||||
<BackButton variant="desktop" />
|
||||
</div>
|
||||
|
||||
{/* CENTER (spacer): the search bar is absolutely positioned below
|
||||
so it anchors to true viewport center regardless of left/right
|
||||
column widths. This empty grid track keeps `auto 1fr auto` so
|
||||
the right column behaves the same as before. */}
|
||||
<div aria-hidden />
|
||||
|
||||
{/* CENTER: global search, anchored to true viewport center.
|
||||
The topbar element starts AFTER the 256px sidebar at lg+, so
|
||||
`left: 50%` of the topbar lands sidebar/2 (=128px) right of the
|
||||
viewport center. We subtract that offset at lg+ so the search
|
||||
bar sits under the browser address bar; below lg the sidebar
|
||||
is hidden behind a Sheet and the topbar spans the full
|
||||
viewport, so plain `left: 50%` is already correct.
|
||||
|
||||
Caps scale by viewport tier so the bar doesn't crowd the side
|
||||
columns. The previous max-w-2xl (672px) at xl ate so much of
|
||||
the topbar that the back-button column on the left got
|
||||
visually clipped by the search bar; tightened to max-w-xl so
|
||||
a "Back to Administration"-class label can render in full:
|
||||
base: max-w-md (28rem)
|
||||
lg: max-w-lg (32rem)
|
||||
xl: max-w-xl (36rem)
|
||||
The wrapper is pointer-events-none so it doesn't capture
|
||||
clicks meant for the left/right columns underneath; only the
|
||||
input itself receives pointer events. */}
|
||||
<div className="pointer-events-none absolute inset-y-0 left-1/2 lg:left-[calc(50%-var(--width-sidebar)/2)] flex w-full max-w-md -translate-x-1/2 items-center px-4 lg:max-w-lg xl:max-w-xl">
|
||||
<div className="pointer-events-auto w-full min-w-0">
|
||||
{/* CENTER: global search lives IN the 1fr grid track so it is
|
||||
bounded by the left (back-button) and right (actions) columns
|
||||
and can never overlap them. The previous approach absolutely
|
||||
positioned the bar at viewport-center, which ignored the side
|
||||
columns and crowded the "New" button at narrower widths
|
||||
(UAT 2026-06-03). `mx-auto` keeps it visually centered within
|
||||
the available middle space; `max-w-xl` stops it sprawling on
|
||||
wide screens; `min-w-0` lets it shrink rather than push the
|
||||
side columns. The grid `gap-3` guarantees breathing room from
|
||||
both neighbours. */}
|
||||
<div className="min-w-0 px-2">
|
||||
<div className="mx-auto w-full min-w-0 max-w-xl">
|
||||
<CommandSearch />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,20 +3,7 @@
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -35,6 +22,10 @@ import type { Route } from 'next';
|
||||
import { Wallet } from 'lucide-react';
|
||||
|
||||
// ─── Payload types (mirror the /api/v1/reports/financial response) ───────────
|
||||
// NOTE: the API still returns expense/cash-flow/net-contribution fields; we
|
||||
// deliberately omit them here. Expenses are business-trip costs tracked in the
|
||||
// standalone Expenses section and do NOT factor into the financial picture, so
|
||||
// the Financial report is purely revenue / deposits / collections.
|
||||
|
||||
interface FinancialKpis {
|
||||
revenueCollected: number;
|
||||
@@ -43,8 +34,6 @@ interface FinancialKpis {
|
||||
refundsIssued: number;
|
||||
pipelineExpected: number;
|
||||
expectedDepositsOutstanding: number;
|
||||
expensesTotal: number;
|
||||
netContribution: number;
|
||||
currency: string;
|
||||
}
|
||||
interface RevenueByMonthRow {
|
||||
@@ -62,15 +51,6 @@ interface AgingRow {
|
||||
count: number;
|
||||
value: number;
|
||||
}
|
||||
interface CashFlowRow {
|
||||
month: string;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
}
|
||||
interface ExpenseBreakdownRow {
|
||||
category: string;
|
||||
total: number;
|
||||
}
|
||||
interface OutstandingDepositRow {
|
||||
interestId: string;
|
||||
clientName: string;
|
||||
@@ -98,16 +78,6 @@ interface RefundRow {
|
||||
currency: string;
|
||||
notes: string | null;
|
||||
}
|
||||
interface ExpenseLedgerRow {
|
||||
id: string;
|
||||
expenseDate: string;
|
||||
payer: string | null;
|
||||
category: string | null;
|
||||
establishmentName: string | null;
|
||||
amount: number;
|
||||
currency: string;
|
||||
paymentStatus: string | null;
|
||||
}
|
||||
|
||||
interface FinancialPayload {
|
||||
data: {
|
||||
@@ -115,12 +85,9 @@ interface FinancialPayload {
|
||||
revenueByMonth: RevenueByMonthRow[];
|
||||
collectionFunnel: CollectionFunnelRow[];
|
||||
aging: AgingRow[];
|
||||
cashFlow: CashFlowRow[];
|
||||
expenseBreakdown: ExpenseBreakdownRow[];
|
||||
outstandingDeposits: OutstandingDepositRow[];
|
||||
recentPayments: RecentPaymentRow[];
|
||||
refundLog: RefundRow[];
|
||||
expenseLedger: ExpenseLedgerRow[];
|
||||
range: { from: string; to: string };
|
||||
hasData: boolean;
|
||||
};
|
||||
@@ -133,15 +100,6 @@ interface FinancialTemplateConfig extends Record<string, unknown> {
|
||||
|
||||
type MonthGranularity = 'month' | 'quarter' | 'year';
|
||||
|
||||
const DONUT_COLORS = [
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-3))',
|
||||
'hsl(var(--chart-4))',
|
||||
'hsl(var(--chart-5))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-6))',
|
||||
];
|
||||
|
||||
export function FinancialReportClient({ portSlug }: { portSlug: string }) {
|
||||
const searchParams = useSearchParams();
|
||||
const initialTemplateId = searchParams?.get('templateId') ?? null;
|
||||
@@ -181,11 +139,9 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
|
||||
const revenueByMonth = d?.revenueByMonth ?? [];
|
||||
const collectionFunnel = d?.collectionFunnel ?? [];
|
||||
const aging = d?.aging ?? [];
|
||||
const expenseBreakdown = d?.expenseBreakdown ?? [];
|
||||
const outstandingDeposits = d?.outstandingDeposits ?? [];
|
||||
const recentPayments = d?.recentPayments ?? [];
|
||||
const refundLog = d?.refundLog ?? [];
|
||||
const expenseLedger = d?.expenseLedger ?? [];
|
||||
|
||||
// Re-bucket the monthly revenue series for the quarter/year toggle.
|
||||
// Depend on the query-data reference (stable across renders once
|
||||
@@ -195,17 +151,13 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
|
||||
() => rebucketRevenue(d?.revenueByMonth ?? [], granularity),
|
||||
[d?.revenueByMonth, granularity],
|
||||
);
|
||||
const cashFlowSeries = useMemo(
|
||||
() => (d?.cashFlow ?? []).map((r) => ({ ...r, label: formatMonthLabel(r.month) })),
|
||||
[d?.cashFlow],
|
||||
);
|
||||
const fundedCount = collectionFunnel.length > 0 ? collectionFunnel[0]!.count : 0;
|
||||
|
||||
function buildExportPayload(): ReportPayload {
|
||||
if (!kpis) throw new Error('Report still loading');
|
||||
return {
|
||||
title: 'Financial',
|
||||
description: 'Revenue collected, deposits, outstanding, cash flow, and expenses.',
|
||||
description: 'Revenue collected, deposits, outstanding balances, and collections.',
|
||||
filenameSlug: 'financial',
|
||||
range: bounds,
|
||||
kpis: [
|
||||
@@ -220,8 +172,6 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
|
||||
label: 'Outstanding deposits',
|
||||
value: formatMoney(kpis.expectedDepositsOutstanding, currency),
|
||||
},
|
||||
{ label: 'Expenses', value: formatMoney(kpis.expensesTotal, currency) },
|
||||
{ label: 'Net contribution', value: formatMoney(kpis.netContribution, currency) },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
@@ -252,23 +202,6 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
|
||||
daysOutstanding: r.daysOutstanding,
|
||||
})),
|
||||
},
|
||||
{
|
||||
title: 'Expense ledger',
|
||||
columns: [
|
||||
{ key: 'expenseDate', label: 'Date' },
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'payer', label: 'Payer' },
|
||||
{ key: 'amount', label: 'Amount', align: 'right' },
|
||||
{ key: 'paymentStatus', label: 'Status' },
|
||||
],
|
||||
rows: expenseLedger.map((r) => ({
|
||||
expenseDate: r.expenseDate ? r.expenseDate.slice(0, 10) : '—',
|
||||
category: r.category ?? '—',
|
||||
payer: r.payer ?? '—',
|
||||
amount: formatMoney(r.amount, r.currency),
|
||||
paymentStatus: r.paymentStatus ?? '—',
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -281,14 +214,14 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
|
||||
<PageHeader
|
||||
eyebrow="Reports"
|
||||
title="Financial"
|
||||
description="Revenue collected, deposits, outstanding balances, cash flow, and expense breakdown."
|
||||
description="Revenue collected, deposits, and outstanding balances."
|
||||
/>
|
||||
<ReportEmptyState
|
||||
icon={Wallet}
|
||||
title="No financial activity yet"
|
||||
body="Record a payment on a deal or log an expense to see revenue, deposits, and cash flow."
|
||||
actionLabel="Go to expenses"
|
||||
actionHref={`/${portSlug}/expenses` as Route}
|
||||
body="Record a payment on a deal to see revenue, deposits, and collections."
|
||||
actionLabel="View deals"
|
||||
actionHref={`/${portSlug}/interests` as Route}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -299,7 +232,7 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
|
||||
<PageHeader
|
||||
eyebrow="Reports"
|
||||
title="Financial"
|
||||
description="Revenue collected, deposits, outstanding balances, cash flow, and expenses. Sourced from recorded payments; the CRM does not invoice."
|
||||
description="Revenue collected, deposits, and outstanding balances. Sourced from recorded payments; the CRM does not invoice."
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<DateRangePicker value={range} onChange={handleRangeChange} />
|
||||
@@ -316,13 +249,13 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI STRIP — 7 tiles */}
|
||||
{/* KPI STRIP — 5 tiles (expenses + net-contribution intentionally omitted) */}
|
||||
<section
|
||||
aria-label="Financial KPIs"
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"
|
||||
>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 7 }).map((_, i) => <KpiSkeleton key={i} />)
|
||||
Array.from({ length: 5 }).map((_, i) => <KpiSkeleton key={i} />)
|
||||
) : (
|
||||
<>
|
||||
<KpiCard
|
||||
@@ -348,21 +281,6 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
|
||||
value={formatMoney(kpis.expectedDepositsOutstanding, currency)}
|
||||
hint="Expected but not yet collected"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Expenses"
|
||||
value={formatMoney(kpis.expensesTotal, currency)}
|
||||
hint={
|
||||
kpis.refundsIssued > 0
|
||||
? `${formatMoney(kpis.refundsIssued, currency)} refunded`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Net contribution"
|
||||
value={formatMoney(kpis.netContribution, currency)}
|
||||
valueTone={kpis.netContribution >= 0 ? 'positive' : 'negative'}
|
||||
hint="Revenue − expenses"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
@@ -526,130 +444,7 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* CHART 4 — Cash flow (inflow vs outflow) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Cash flow</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Money in (payments received) vs money out (expenses booked), per month.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[280px] w-full" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={cashFlowSeries} margin={{ top: 8, right: 8, left: 4, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatMoneyCompact(Number(v), currency)}
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
width={64}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value, name) => [
|
||||
formatMoney(Number(value), currency),
|
||||
name === 'inflow' ? 'Inflow' : 'Outflow',
|
||||
]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="inflow"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="outflow"
|
||||
stroke="hsl(var(--chart-4))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* CHART 5 — Expense breakdown donut + Recent payments table */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Expense breakdown</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">By category, for the selected period.</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[260px] w-full" />
|
||||
) : expenseBreakdown.length === 0 ? (
|
||||
<EmptyState>No expenses booked in this period.</EmptyState>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={expenseBreakdown}
|
||||
dataKey="total"
|
||||
nameKey="category"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{expenseBreakdown.map((_, i) => (
|
||||
<Cell key={i} fill={DONUT_COLORS[i % DONUT_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value, name) => [
|
||||
formatMoney(Number(value), currency),
|
||||
String(name),
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Recent payments</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Latest money received.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{isLoading ? (
|
||||
<Skeleton className="mx-6 h-[260px]" />
|
||||
) : recentPayments.length === 0 ? (
|
||||
<EmptyState>No payments recorded in this period.</EmptyState>
|
||||
) : (
|
||||
<SimpleTable
|
||||
head={['Date', 'Client', 'Type', 'Amount']}
|
||||
rows={recentPayments.slice(0, 8).map((p) => [
|
||||
p.receivedAt ? p.receivedAt.slice(0, 10) : '—',
|
||||
p.clientName,
|
||||
<span key="t" className="capitalize">
|
||||
{p.paymentType}
|
||||
</span>,
|
||||
<span key="a" className="tabular-nums">
|
||||
{formatMoney(p.amount, p.currency)}
|
||||
</span>,
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* TABLES — Outstanding deposits + Expense ledger + Refunds */}
|
||||
{/* TABLE — Outstanding deposits (the chase list) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Outstanding deposits</CardTitle>
|
||||
@@ -684,29 +479,29 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* TABLES — Recent payments + Refunds */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Expense ledger</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Expenses booked in the period.</p>
|
||||
<CardTitle className="text-base">Recent payments</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Latest money received.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{isLoading ? (
|
||||
<Skeleton className="mx-6 h-[200px]" />
|
||||
) : expenseLedger.length === 0 ? (
|
||||
<EmptyState>No expenses booked in this period.</EmptyState>
|
||||
<Skeleton className="mx-6 h-[260px]" />
|
||||
) : recentPayments.length === 0 ? (
|
||||
<EmptyState>No payments recorded in this period.</EmptyState>
|
||||
) : (
|
||||
<SimpleTable
|
||||
head={['Date', 'Category', 'Payer', 'Amount', 'Status']}
|
||||
rows={expenseLedger.slice(0, 10).map((r) => [
|
||||
r.expenseDate ? r.expenseDate.slice(0, 10) : '—',
|
||||
r.category ?? '—',
|
||||
r.payer ?? '—',
|
||||
<span key="a" className="tabular-nums">
|
||||
{formatMoney(r.amount, r.currency)}
|
||||
head={['Date', 'Client', 'Type', 'Amount']}
|
||||
rows={recentPayments.slice(0, 8).map((p) => [
|
||||
p.receivedAt ? p.receivedAt.slice(0, 10) : '—',
|
||||
p.clientName,
|
||||
<span key="t" className="capitalize">
|
||||
{p.paymentType}
|
||||
</span>,
|
||||
<span key="s" className="capitalize">
|
||||
{r.paymentStatus ?? '—'}
|
||||
<span key="a" className="tabular-nums">
|
||||
{formatMoney(p.amount, p.currency)}
|
||||
</span>,
|
||||
])}
|
||||
/>
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ResidentialClientsList() {
|
||||
|
||||
{/* Desktop: table layout. Hidden below lg because the 6 columns clip
|
||||
off the viewport at phone widths. */}
|
||||
<div className="hidden lg:block rounded-lg border bg-card overflow-hidden">
|
||||
<div className="hidden md:block rounded-lg border bg-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
@@ -181,7 +181,7 @@ export function ResidentialClientsList() {
|
||||
|
||||
{/* Mobile: card list. Each card mirrors the table row data with
|
||||
name + status pill on top, then meta line(s) below. */}
|
||||
<div className="lg:hidden space-y-2">
|
||||
<div className="md:hidden space-y-2">
|
||||
{isLoading && (
|
||||
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
|
||||
@@ -244,7 +244,7 @@ export function DataTable<TData>({
|
||||
<div
|
||||
ref={virtualEnabled ? scrollContainerRef : undefined}
|
||||
style={virtualEnabled ? { height: virtualHeightPx, overflow: 'auto' } : undefined}
|
||||
className={cn('rounded-md border overflow-x-auto', cardRender && 'hidden lg:block')}
|
||||
className={cn('rounded-md border overflow-x-auto', cardRender && 'hidden md:block')}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-muted/50">
|
||||
@@ -373,7 +373,7 @@ export function DataTable<TData>({
|
||||
|
||||
{/* Mobile card list */}
|
||||
{cardRender && (
|
||||
<ul className="lg:hidden flex flex-col gap-2">
|
||||
<ul className="md:hidden flex flex-col gap-2">
|
||||
{isLoading ? (
|
||||
<li className="rounded-md border bg-card p-6 text-center">
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin text-muted-foreground" aria-hidden />
|
||||
|
||||
@@ -43,7 +43,7 @@ export function DataView<TData>({
|
||||
{headerSlot ? <div>{headerSlot}</div> : null}
|
||||
|
||||
{/* Desktop: TanStack table */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((group) => (
|
||||
@@ -81,7 +81,7 @@ export function DataView<TData>({
|
||||
</div>
|
||||
|
||||
{/* Mobile: card list */}
|
||||
<ul className="lg:hidden flex flex-col gap-2">
|
||||
<ul className="md:hidden flex flex-col gap-2">
|
||||
{isEmpty ? (
|
||||
<li className="rounded-md border border-border bg-card p-4 text-center text-sm text-muted-foreground">
|
||||
{emptyState ?? 'No results.'}
|
||||
|
||||
@@ -34,8 +34,28 @@ export const PREVIEWABLE_MIMES = new Set<string>([
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
// Plain text + CSV render via the in-app TextPreview.
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
// Office formats: .docx renders client-side via docx-preview; the
|
||||
// legacy/binary + spreadsheet formats fall through to a download CTA in
|
||||
// the preview dialog. They're allow-listed here so the preview endpoint
|
||||
// returns a URL instead of rejecting the file outright (which surfaced
|
||||
// as a misleading "Failed to load preview"). We deliberately do NOT use
|
||||
// Microsoft's hosted Office viewer — it can't reach our private storage.
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
|
||||
'application/msword', // .doc
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
||||
'application/vnd.ms-excel', // .xls
|
||||
]);
|
||||
|
||||
/** True when the file is an OOXML Word document we can render in-browser. */
|
||||
export function isWordDocx(mimeType: string | undefined, fileName: string | undefined): boolean {
|
||||
if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
|
||||
return true;
|
||||
return (fileName ?? '').toLowerCase().endsWith('.docx');
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic-byte signatures keyed by claimed MIME type. Used by the file
|
||||
* upload handler to reject files whose first few bytes don't match the
|
||||
|
||||
@@ -20,7 +20,7 @@ const DEFAULT_COLUMNS: ReadonlyArray<{ key: string; label: string; widthPct: num
|
||||
{ key: 'primaryEmail', label: 'Email', widthPct: 25 },
|
||||
{ key: 'primaryPhone', label: 'Phone', widthPct: 15 },
|
||||
{ key: 'source', label: 'Source', widthPct: 12 },
|
||||
{ key: 'nationality', label: 'Nationality', widthPct: 8 },
|
||||
{ key: 'nationality', label: 'Country', widthPct: 8 },
|
||||
{ key: 'createdAt', label: 'Created', widthPct: 10 },
|
||||
];
|
||||
|
||||
|
||||
@@ -579,170 +579,135 @@ export function DashboardReport({
|
||||
) : null}
|
||||
|
||||
{include('recent_activity') && data.recentActivity && data.recentActivity.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Recent activity</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Last entries from the audit log, compact snapshot.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['When', 'Who', 'Summary']}
|
||||
widths={[18, 22, 60]}
|
||||
rows={data.recentActivity.map((row) => [
|
||||
new Date(row.when).toLocaleString('en-GB'),
|
||||
row.actor ?? 'system',
|
||||
row.summary,
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
<TableSection
|
||||
styles={styles}
|
||||
title="Recent activity"
|
||||
subtitle="Last entries from the audit log, compact snapshot."
|
||||
headers={['When', 'Who', 'Summary']}
|
||||
widths={[18, 22, 60]}
|
||||
rows={data.recentActivity.map((row) => [
|
||||
new Date(row.when).toLocaleString('en-GB'),
|
||||
row.actor ?? 'system',
|
||||
row.summary,
|
||||
])}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{include('new_clients_period') &&
|
||||
data.newClientsInPeriod &&
|
||||
data.newClientsInPeriod.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>New clients (in period)</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Clients added during the report window with their lead source. Capped at 50 rows; full
|
||||
list lives in the client export.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Client', 'Source', 'Added']}
|
||||
widths={[55, 25, 20]}
|
||||
rows={data.newClientsInPeriod.map((r) => [
|
||||
r.name,
|
||||
r.source ?? '-',
|
||||
new Date(r.createdAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
<TableSection
|
||||
styles={styles}
|
||||
title="New clients (in period)"
|
||||
subtitle="Clients added during the report window with their lead source. Capped at 50 rows; full list lives in the client export."
|
||||
headers={['Client', 'Source', 'Added']}
|
||||
widths={[55, 25, 20]}
|
||||
rows={data.newClientsInPeriod.map((r) => [
|
||||
r.name,
|
||||
r.source ?? '-',
|
||||
new Date(r.createdAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{include('new_interests_period') &&
|
||||
data.newInterestsInPeriod &&
|
||||
data.newInterestsInPeriod.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>New interests (in period)</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Interests opened during the report window, with the stage they currently sit at and the
|
||||
berth(s) attached.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Client', 'Stage', 'Berth', 'Opened']}
|
||||
widths={[35, 22, 23, 20]}
|
||||
rows={data.newInterestsInPeriod.map((r) => [
|
||||
r.clientName,
|
||||
stageLabel(r.stage),
|
||||
r.berthLabel ?? '-',
|
||||
new Date(r.createdAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
<TableSection
|
||||
styles={styles}
|
||||
title="New interests (in period)"
|
||||
subtitle="Interests opened during the report window, with the stage they currently sit at and the berth(s) attached."
|
||||
headers={['Client', 'Stage', 'Berth', 'Opened']}
|
||||
widths={[35, 22, 23, 20]}
|
||||
rows={data.newInterestsInPeriod.map((r) => [
|
||||
r.clientName,
|
||||
stageLabel(r.stage),
|
||||
r.berthLabel ?? '-',
|
||||
new Date(r.createdAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{include('berths_sold_period') &&
|
||||
data.berthsSoldInPeriod &&
|
||||
data.berthsSoldInPeriod.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Berths sold (in period)</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Berths transitioned to Sold status during the report window, resolved from the audit
|
||||
log.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Mooring', 'Sold on']}
|
||||
widths={[50, 50]}
|
||||
rows={data.berthsSoldInPeriod.map((r) => [
|
||||
r.mooringNumber,
|
||||
new Date(r.soldAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
<TableSection
|
||||
styles={styles}
|
||||
title="Berths sold (in period)"
|
||||
subtitle="Berths transitioned to Sold status during the report window, resolved from the audit log."
|
||||
headers={['Mooring', 'Sold on']}
|
||||
widths={[50, 50]}
|
||||
rows={data.berthsSoldInPeriod.map((r) => [
|
||||
r.mooringNumber,
|
||||
new Date(r.soldAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{include('signed_documents_period') &&
|
||||
data.signedDocumentsInPeriod &&
|
||||
data.signedDocumentsInPeriod.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Documents signed (in period)</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
EOIs, reservations, and contracts marked completed during the report window.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Type', 'Title', 'Signed on']}
|
||||
widths={[20, 55, 25]}
|
||||
rows={data.signedDocumentsInPeriod.map((r) => [
|
||||
r.type,
|
||||
r.title,
|
||||
new Date(r.signedAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
<TableSection
|
||||
styles={styles}
|
||||
title="Documents signed (in period)"
|
||||
subtitle="EOIs, reservations, and contracts marked completed during the report window."
|
||||
headers={['Type', 'Title', 'Signed on']}
|
||||
widths={[20, 55, 25]}
|
||||
rows={data.signedDocumentsInPeriod.map((r) => [
|
||||
r.type,
|
||||
r.title,
|
||||
new Date(r.signedAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{include('contracts_signed_period') &&
|
||||
data.contractsSignedInPeriod &&
|
||||
data.contractsSignedInPeriod.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Contracts signed (in period)</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Contract documents that completed signing during the report window.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Title', 'Signed on']}
|
||||
widths={[75, 25]}
|
||||
rows={data.contractsSignedInPeriod.map((r) => [
|
||||
r.title,
|
||||
new Date(r.signedAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
<TableSection
|
||||
styles={styles}
|
||||
title="Contracts signed (in period)"
|
||||
subtitle="Contract documents that completed signing during the report window."
|
||||
headers={['Title', 'Signed on']}
|
||||
widths={[75, 25]}
|
||||
rows={data.contractsSignedInPeriod.map((r) => [
|
||||
r.title,
|
||||
new Date(r.signedAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{include('deposits_received_period') &&
|
||||
data.depositsReceivedInPeriod &&
|
||||
data.depositsReceivedInPeriod.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Deposits received (in period)</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Deposit payments received during the report window, with client + $ amount.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Client', 'Amount', 'Date']}
|
||||
widths={[55, 25, 20]}
|
||||
rows={data.depositsReceivedInPeriod.map((r) => [
|
||||
r.clientName,
|
||||
formatCurrency(String(r.amount), r.currency, { maxFractionDigits: 0 }),
|
||||
new Date(r.paidAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
<TableSection
|
||||
styles={styles}
|
||||
title="Deposits received (in period)"
|
||||
subtitle="Deposit payments received during the report window, with client + $ amount."
|
||||
headers={['Client', 'Amount', 'Date']}
|
||||
widths={[55, 25, 20]}
|
||||
rows={data.depositsReceivedInPeriod.map((r) => [
|
||||
r.clientName,
|
||||
formatCurrency(String(r.amount), r.currency, { maxFractionDigits: 0 }),
|
||||
new Date(r.paidAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{include('hot_deals') && data.hotDeals && data.hotDeals.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Hot deals</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Top active interests, ranked by pipeline stage with most-recent activity as tiebreaker.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Client', 'Mooring', 'Stage', 'Last contact']}
|
||||
widths={[40, 20, 20, 20]}
|
||||
rows={data.hotDeals.map((d) => [
|
||||
d.clientName ?? '-',
|
||||
d.mooringNumber ?? '-',
|
||||
stageLabel(d.stage),
|
||||
d.lastContact ? new Date(d.lastContact).toLocaleDateString('en-GB') : '-',
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
<TableSection
|
||||
styles={styles}
|
||||
title="Hot deals"
|
||||
subtitle="Top active interests, ranked by pipeline stage with most-recent activity as tiebreaker."
|
||||
headers={['Client', 'Mooring', 'Stage', 'Last contact']}
|
||||
widths={[40, 20, 20, 20]}
|
||||
rows={data.hotDeals.map((d) => [
|
||||
d.clientName ?? '-',
|
||||
d.mooringNumber ?? '-',
|
||||
stageLabel(d.stage),
|
||||
d.lastContact ? new Date(d.lastContact).toLocaleDateString('en-GB') : '-',
|
||||
])}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Pending-resolver placeholder. Lets the user see that a
|
||||
@@ -788,7 +753,11 @@ interface SimpleTableProps {
|
||||
function SimpleTable({ styles, headers, widths, rows }: SimpleTableProps) {
|
||||
return (
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
{/* `minPresenceAhead` keeps the header attached to at least ~48pt
|
||||
of body rows: if the header would otherwise land at the very
|
||||
bottom of a page it moves to the next page WITH its rows rather
|
||||
than orphaning. */}
|
||||
<View style={styles.tableHeader} minPresenceAhead={48}>
|
||||
{headers.map((header, i) => (
|
||||
<Text key={header + i} style={{ ...styles.tableHeaderCell, width: `${widths[i]}%` }}>
|
||||
{header}
|
||||
@@ -796,7 +765,15 @@ function SimpleTable({ styles, headers, widths, rows }: SimpleTableProps) {
|
||||
))}
|
||||
</View>
|
||||
{rows.map((row, rowIdx) => (
|
||||
<View key={rowIdx} style={rowIdx % 2 === 1 ? styles.tableRowZebra : styles.tableRow}>
|
||||
// `wrap={false}` per row so a cell that wraps to two lines (long
|
||||
// document filenames) never splits across a page boundary — the
|
||||
// whole row moves to the next page intact rather than rendering
|
||||
// half on each, which reads as overlapping text.
|
||||
<View
|
||||
key={rowIdx}
|
||||
style={rowIdx % 2 === 1 ? styles.tableRowZebra : styles.tableRow}
|
||||
wrap={false}
|
||||
>
|
||||
{row.map((cell, i) => (
|
||||
<Text key={`${rowIdx}-${i}`} style={{ ...styles.tableCell, width: `${widths[i]}%` }}>
|
||||
{cell}
|
||||
@@ -807,3 +784,38 @@ function SimpleTable({ styles, headers, widths, rows }: SimpleTableProps) {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Section wrapper for list-style widgets whose row count is unbounded
|
||||
* (the per-period lists, recent activity, hot deals). Unlike the
|
||||
* fixed-size KPI/chart sections, these MUST be allowed to paginate: a
|
||||
* `wrap={false}` around an oversized table forces React-PDF to render
|
||||
* it crushed / overlapping when it can't fit a single page. Here we let
|
||||
* the table flow across pages and use `minPresenceAhead` on the heading
|
||||
* so the title isn't orphaned at a page bottom away from its rows.
|
||||
*/
|
||||
function TableSection({
|
||||
styles,
|
||||
title,
|
||||
subtitle,
|
||||
headers,
|
||||
widths,
|
||||
rows,
|
||||
}: {
|
||||
styles: ReturnType<typeof makeReportStyles>;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
headers: string[];
|
||||
widths: number[];
|
||||
rows: string[][];
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle} minPresenceAhead={72}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.sectionSubtitle}>{subtitle}</Text>
|
||||
<SimpleTable styles={styles} headers={headers} widths={widths} rows={rows} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,12 +99,19 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
|
||||
eq(interests.portId, portId),
|
||||
inArray(interests.pipelineStage, STALE_STAGES),
|
||||
isNull(interests.archivedAt),
|
||||
// An interest can't be "stale for 14+ days" if it has only existed for
|
||||
// less than 14 days. Without this floor, a bulk import (which backdates
|
||||
// dateLastContact to the legacy value) instantly flags every migrated
|
||||
// interest as stale and floods the alert rail. The 14-day clock starts
|
||||
// no earlier than when the interest entered THIS system.
|
||||
lt(interests.createdAt, daysAgo(14)),
|
||||
// An interest can't be "stale for 14+ days" if it has only existed in
|
||||
// THIS system for less than 14 days. Without this floor, a bulk import
|
||||
// (which backdates dateLastContact to the legacy value) instantly flags
|
||||
// every migrated interest as stale and floods the alert rail.
|
||||
//
|
||||
// We floor on updatedAt, NOT createdAt: the legacy→CRM migration
|
||||
// backfilled created_at to each interest's real origination date (so
|
||||
// analytics date-ranges work), which would make every migrated row look
|
||||
// 14+ days old and re-open the flood. updated_at is left at the
|
||||
// migration timestamp, so it's the reliable "entered/last-touched this
|
||||
// system" clock — migrated rows stay suppressed for 14 days, then the
|
||||
// contact-based OR below governs.
|
||||
lt(interests.updatedAt, daysAgo(14)),
|
||||
or(
|
||||
lt(interests.dateLastContact, daysAgo(14)),
|
||||
and(isNull(interests.dateLastContact), lt(interests.updatedAt, daysAgo(14))),
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
files,
|
||||
} from '@/lib/db/schema/documents';
|
||||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
||||
import { companies } from '@/lib/db/schema/companies';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
@@ -1345,6 +1345,56 @@ async function sendCascadingInviteForNextSigner(doc: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually (re)send the finalized signed PDF of a completed document to the
|
||||
* deal's client. Mirrors the automatic completion fan-out (sendSigningCompleted)
|
||||
* but targets just the client recipient — backs the "Send signed copy to
|
||||
* client" affordance on the EOI tab / document detail. Safe to call repeatedly.
|
||||
*/
|
||||
export async function sendSignedCopyToClient(
|
||||
documentId: string,
|
||||
portId: string,
|
||||
): Promise<{ recipientEmail: string }> {
|
||||
const doc = await getDocumentById(documentId, portId);
|
||||
if (doc.status !== 'completed' || !doc.signedFileId) {
|
||||
throw new ValidationError('This document has no signed PDF to send yet.');
|
||||
}
|
||||
const owner = await resolveDocumentOwner(portId, doc);
|
||||
if (!owner || owner.entityType !== 'client') {
|
||||
throw new ValidationError('No client is linked to this document to send the signed copy to.');
|
||||
}
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, owner.entityId),
|
||||
columns: { fullName: true },
|
||||
});
|
||||
// Primary email via the same is_primary-desc, created_at-desc picker the
|
||||
// clients list uses, scoped to the email channel.
|
||||
const [emailRow] = await db
|
||||
.select({ value: clientContacts.value })
|
||||
.from(clientContacts)
|
||||
.where(and(eq(clientContacts.clientId, owner.entityId), eq(clientContacts.channel, 'email')))
|
||||
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.createdAt))
|
||||
.limit(1);
|
||||
if (!emailRow?.value) {
|
||||
throw new ValidationError('The linked client has no email address on file.');
|
||||
}
|
||||
const portRow = await db.query.ports.findFirst({
|
||||
where: eq(ports.id, portId),
|
||||
columns: { name: true },
|
||||
});
|
||||
await sendSigningCompleted({
|
||||
portId,
|
||||
portName: portRow?.name ?? 'Port Nimara',
|
||||
recipients: [{ name: client?.fullName ?? '', email: emailRow.value }],
|
||||
clientName: client?.fullName ?? doc.title,
|
||||
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
||||
completedAt: new Date(),
|
||||
signedPdfFileId: doc.signedFileId,
|
||||
signedPdfFilename: `signed-${doc.id}.pdf`,
|
||||
});
|
||||
return { recipientEmail: emailRow.value };
|
||||
}
|
||||
|
||||
// ─── Owner-wins resolution ────────────────────────────────────────────────────
|
||||
|
||||
interface ResolvedOwner {
|
||||
|
||||
Reference in New Issue
Block a user