feat(uat): file preview/download fix, clients-by-country page, residential column picker
Batch #4 UAT items. 1. Documents — clicking any file dumped raw presigned-URL JSON. Was systemic: 6 surfaces linked a browser directly at the JSON-returning /files/[id]/{download,preview} routes. Those routes now 302-redirect when called with ?redirect=1 (default stays JSON for the dialog + interest-eoi-tab programmatic consumers); the six <Link> sites use it. The documents-hub file row now opens the inline FilePreviewDialog + has a per-row Download button, and the preview dialog header gained a persistent Download button for all file types. 2. Clients-by-country — the widget's "+N more" dead text is now a "Show all" link to a new /clients/by-country page rendering the full ranked country breakdown (each row drills into the filtered list). 3. Residential clients list — moved off its bespoke table onto the shared DataTable + ColumnPicker (same UX as clients/interests). Adds a "Date added" column, default-hides the empty "Residence" column, preserves the mobile card view, persists per-user column choices. tsc clean, eslint clean, 1584/1584 vitest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
import { ClientsByCountryPage } from '@/components/clients/clients-by-country-page';
|
||||||
|
|
||||||
|
export default function ClientsByCountryRoute() {
|
||||||
|
return <ClientsByCountryPage />;
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@ export const GET = withAuth(
|
|||||||
withPermission('files', 'view', async (req, ctx, params) => {
|
withPermission('files', 'view', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const result = await getDownloadUrl(params.id!, ctx.portId);
|
const result = await getDownloadUrl(params.id!, ctx.portId);
|
||||||
|
// `?redirect=1` → 302 straight to the presigned (attachment) URL so a
|
||||||
|
// plain <a href>/<Link> downloads the file. Without it we return the
|
||||||
|
// JSON envelope for programmatic consumers (e.g. fetch + anchor click).
|
||||||
|
// Linking the browser at the JSON form used to dump raw `{data:{url}}`.
|
||||||
|
if (new URL(req.url).searchParams.has('redirect')) {
|
||||||
|
return NextResponse.redirect(result.url, 302);
|
||||||
|
}
|
||||||
return NextResponse.json({ data: result });
|
return NextResponse.json({ data: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export const GET = withAuth(
|
|||||||
withPermission('files', 'view', async (req, ctx, params) => {
|
withPermission('files', 'view', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const result = await getPreviewUrl(params.id!, ctx.portId);
|
const result = await getPreviewUrl(params.id!, ctx.portId);
|
||||||
|
// `?redirect=1` → 302 to the presigned (inline) URL so a plain
|
||||||
|
// <a href>/<Link> opens the file in the browser. Default returns the
|
||||||
|
// JSON envelope for programmatic consumers (e.g. FilePreviewDialog).
|
||||||
|
if (new URL(req.url).searchParams.has('redirect')) {
|
||||||
|
return NextResponse.redirect(result.url, 302);
|
||||||
|
}
|
||||||
return NextResponse.json({ data: result });
|
return NextResponse.json({ data: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
|
|||||||
119
src/components/clients/clients-by-country-page.tsx
Normal file
119
src/components/clients/clients-by-country-page.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Globe } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { CountryFlag } from '@/components/shared/country-flag';
|
||||||
|
import { getCountryName } from '@/lib/i18n/countries';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ClientsByCountryRow {
|
||||||
|
country: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientsByCountryResponse {
|
||||||
|
data: ClientsByCountryRow[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full per-country breakdown of the active (non-archived) client book — the
|
||||||
|
* "Show all" destination for the dashboard `ClientsByCountryWidget`, which
|
||||||
|
* only shows the top N. Same endpoint (it already returns every row); this
|
||||||
|
* page just renders the complete ranked list. Each row deep-links into the
|
||||||
|
* clients list filtered by that nationality.
|
||||||
|
*/
|
||||||
|
export function ClientsByCountryPage() {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<ClientsByCountryResponse>({
|
||||||
|
queryKey: ['dashboard', 'clients-by-country', 'all'],
|
||||||
|
queryFn: () => apiFetch<ClientsByCountryResponse>('/api/v1/dashboard/clients-by-country'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = data?.data ?? [];
|
||||||
|
const total = data?.total ?? rows.reduce((s, r) => s + r.count, 0);
|
||||||
|
const maxCount = rows.reduce((m, r) => Math.max(m, r.count), 0) || 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Clients"
|
||||||
|
title="Clients by country"
|
||||||
|
description="Every country represented in the active client book, ranked by client count. Select a row to view those clients."
|
||||||
|
kpiLine={
|
||||||
|
rows.length > 0
|
||||||
|
? `${total} client${total === 1 ? '' : 's'} across ${rows.length} ${
|
||||||
|
rows.length === 1 ? 'country' : 'countries'
|
||||||
|
}.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 sm:p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-8 w-full" aria-hidden />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="flex h-40 flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
|
||||||
|
<Globe className="size-6" aria-hidden />
|
||||||
|
<p>No clients with a country recorded yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-1">
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const pct = (row.count / maxCount) * 100;
|
||||||
|
const name = getCountryName(row.country) || row.country;
|
||||||
|
return (
|
||||||
|
<li key={row.country}>
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
`/${portSlug}/clients?nationality=${encodeURIComponent(row.country)}` as Route
|
||||||
|
}
|
||||||
|
className="group flex items-center justify-between gap-3 rounded-md px-2 py-2 hover:bg-foreground/5"
|
||||||
|
title={`${row.count} client${row.count === 1 ? '' : 's'} in ${name}`}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<span className="w-6 shrink-0 text-end text-xs tabular-nums text-muted-foreground">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<CountryFlag code={row.country} className="h-3.5 w-5" decorative />
|
||||||
|
<span className="truncate text-sm font-medium">{name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
|
<div className="h-1.5 w-32 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={cn('h-full rounded-full bg-brand-500')}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-10 text-end text-sm tabular-nums text-foreground">
|
||||||
|
{row.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import Link from 'next/link';
|
|||||||
import type { Route } from 'next';
|
import type { Route } from 'next';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Globe } from 'lucide-react';
|
import { ArrowRight, Globe } from 'lucide-react';
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
@@ -126,8 +126,14 @@ export function ClientsByCountryWidget({ limit = 8 }: { limit?: number } = {}) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{hiddenCount > 0 ? (
|
{hiddenCount > 0 ? (
|
||||||
<li className="pt-1 text-xs text-muted-foreground">
|
<li className="border-t pt-2 text-right">
|
||||||
+ {hiddenCount} more {hiddenCount === 1 ? 'country' : 'countries'} not shown.
|
<Link
|
||||||
|
href={`/${portSlug}/clients/by-country` as Route}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Show all {rows.length} countries
|
||||||
|
<ArrowRight className="size-3" aria-hidden />
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
|
|||||||
{isComplete && doc.signedFileId ? (
|
{isComplete && doc.signedFileId ? (
|
||||||
<>
|
<>
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm">
|
||||||
<Link href={`/api/v1/files/${doc.signedFileId}/download`}>
|
<Link href={`/api/v1/files/${doc.signedFileId}/download?redirect=1`}>
|
||||||
<Download className="mr-1.5 h-4 w-4" aria-hidden /> Download signed PDF
|
<Download className="mr-1.5 h-4 w-4" aria-hidden /> Download signed PDF
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ChevronDown, ChevronRight, FileText, Folder, Lock, Plus, Upload } from 'lucide-react';
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Folder,
|
||||||
|
Lock,
|
||||||
|
Plus,
|
||||||
|
Upload,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -16,9 +25,11 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||||
import { EmptyState } from '@/components/ui/empty-state';
|
import { EmptyState } from '@/components/ui/empty-state';
|
||||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||||
|
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
@@ -336,6 +347,30 @@ function FlatFolderListing({
|
|||||||
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
|
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
|
||||||
const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
|
const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
|
||||||
const [uploadOpen, setUploadOpen] = useState(false);
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
// File selected for inline preview. Clicking a file row opens the shared
|
||||||
|
// FilePreviewDialog rather than navigating the browser at the JSON-returning
|
||||||
|
// `/files/[id]/download` endpoint (which used to dump raw `{data:{url}}`).
|
||||||
|
const [previewFile, setPreviewFile] = useState<HubFile | null>(null);
|
||||||
|
|
||||||
|
// Force-download a stored file: the `/download` route returns a presigned
|
||||||
|
// URL (content-disposition=attachment) as a JSON envelope, so we fetch it
|
||||||
|
// then click a hidden anchor. Avoids navigating the tab to the raw JSON.
|
||||||
|
const downloadFile = useCallback(async (file: HubFile) => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiFetch<{ data: { url: string; filename: string } }>(
|
||||||
|
`/api/v1/files/${file.id}/download`,
|
||||||
|
);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = data.url;
|
||||||
|
a.rel = 'noopener';
|
||||||
|
a.download = file.originalName ?? file.filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
} catch {
|
||||||
|
// apiFetch surfaces its own toast on failure; nothing else to do here.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const queryParams = useMemo(() => {
|
const queryParams = useMemo(() => {
|
||||||
@@ -489,9 +524,10 @@ function FlatFolderListing({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Uploaded-file row — simpler than a signature doc since there's no
|
// Uploaded-file row — simpler than a signature doc since there's no
|
||||||
// signer/status concept. Links to the underlying file via download URL
|
// signer/status concept. Clicking the name opens an inline preview
|
||||||
// and surfaces an "Uploaded" type pill so the rep distinguishes it
|
// (FilePreviewDialog); a dedicated download button saves the file. An
|
||||||
// from signature workflows at a glance.
|
// "Uploaded" type pill distinguishes it from signature workflows.
|
||||||
|
const fileLabel = (file: HubFile) => file.originalName ?? file.filename;
|
||||||
const renderFileRow = (file: HubFile) => {
|
const renderFileRow = (file: HubFile) => {
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
@@ -501,14 +537,14 @@ function FlatFolderListing({
|
|||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3">
|
||||||
{/* Empty action column to align with doc-row layout */}
|
{/* Empty action column to align with doc-row layout */}
|
||||||
<span className="hidden h-[44px] w-[44px] sm:block" aria-hidden />
|
<span className="hidden h-[44px] w-[44px] sm:block" aria-hidden />
|
||||||
<a
|
<button
|
||||||
href={`/api/v1/files/${file.id}/download`}
|
type="button"
|
||||||
target="_blank"
|
onClick={() => setPreviewFile(file)}
|
||||||
rel="noreferrer"
|
className="min-w-0 truncate text-left font-medium text-foreground hover:text-brand"
|
||||||
className="min-w-0 truncate font-medium text-foreground hover:text-brand"
|
title={`Preview ${fileLabel(file)}`}
|
||||||
>
|
>
|
||||||
{file.originalName ?? file.filename}
|
{fileLabel(file)}
|
||||||
</a>
|
</button>
|
||||||
<span className="text-xs text-muted-foreground">Uploaded file</span>
|
<span className="text-xs text-muted-foreground">Uploaded file</span>
|
||||||
<StatusPill status="completed" withDot>
|
<StatusPill status="completed" withDot>
|
||||||
Stored
|
Stored
|
||||||
@@ -516,9 +552,21 @@ function FlatFolderListing({
|
|||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
{(file.sizeBytes / 1024).toFixed(0)} KB
|
{(file.sizeBytes / 1024).toFixed(0)} KB
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<div className="flex items-center gap-2">
|
||||||
{new Date(file.createdAt).toLocaleDateString(undefined)}
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
</span>
|
{new Date(file.createdAt).toLocaleDateString(undefined)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => void downloadFile(file)}
|
||||||
|
title={`Download ${fileLabel(file)}`}
|
||||||
|
aria-label={`Download ${fileLabel(file)}`}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -526,6 +574,15 @@ function FlatFolderListing({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<FilePreviewDialog
|
||||||
|
open={!!previewFile}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setPreviewFile(null);
|
||||||
|
}}
|
||||||
|
fileId={previewFile?.id}
|
||||||
|
fileName={previewFile ? fileLabel(previewFile) : undefined}
|
||||||
|
mimeType={previewFile?.mimeType ?? undefined}
|
||||||
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by title..."
|
placeholder="Search by title..."
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function ReceiptThumbnail({ fileId }: { fileId: string }) {
|
|||||||
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span className="truncate">{mime || (isError ? 'Receipt' : 'File')}</span>
|
<span className="truncate">{mime || (isError ? 'Receipt' : 'File')}</span>
|
||||||
<a
|
<a
|
||||||
href={`/api/v1/files/${fileId}/download`}
|
href={`/api/v1/files/${fileId}/download?redirect=1`}
|
||||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
className="inline-flex items-center gap-1 text-primary hover:underline"
|
||||||
>
|
>
|
||||||
<Download className="h-3 w-3" aria-hidden /> Download
|
<Download className="h-3 w-3" aria-hidden /> Download
|
||||||
|
|||||||
@@ -121,12 +121,24 @@ export function FilePreviewDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 truncate">
|
<DialogTitle className="flex items-center gap-2 truncate">
|
||||||
<span className="truncate">{fileName ?? 'Preview'}</span>
|
<span className="truncate">{fileName ?? 'Preview'}</span>
|
||||||
|
{fileId && (
|
||||||
|
<a
|
||||||
|
href={`/api/v1/files/${fileId}/download?redirect=1`}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
title="Download"
|
||||||
|
aria-label={`Download ${fileName ?? 'file'}`}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
{previewUrl && (
|
{previewUrl && (
|
||||||
<a
|
<a
|
||||||
href={previewUrl}
|
href={previewUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
title="Open in new tab"
|
||||||
|
aria-label="Open in new tab"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ export function InvoiceCard({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{invoice.pdfFileId ? (
|
{invoice.pdfFileId ? (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/api/v1/files/${invoice.pdfFileId}/preview`} target="_blank">
|
<Link
|
||||||
|
href={`/api/v1/files/${invoice.pdfFileId}/preview?redirect=1`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
<FileText className="mr-2 h-3.5 w-3.5" />
|
<FileText className="mr-2 h-3.5 w-3.5" />
|
||||||
View PDF
|
View PDF
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -144,7 +144,10 @@ export function getInvoiceColumns({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{invoice.pdfFileId && (
|
{invoice.pdfFileId && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/api/v1/files/${invoice.pdfFileId}/preview`} target="_blank">
|
<Link
|
||||||
|
href={`/api/v1/files/${invoice.pdfFileId}/preview?redirect=1`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
<FileText className="mr-2 h-3.5 w-3.5" />
|
<FileText className="mr-2 h-3.5 w-3.5" />
|
||||||
View PDF
|
View PDF
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ export function ReportRunsPageClient({ portSlug }: { portSlug: string }) {
|
|||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{r.status === 'complete' && r.storageKey ? (
|
{r.status === 'complete' && r.storageKey ? (
|
||||||
<Button asChild size="sm" variant="ghost" title="Download artefact">
|
<Button asChild size="sm" variant="ghost" title="Download artefact">
|
||||||
<Link href={`/api/v1/files/${r.storageKey}/download` as Route}>
|
<Link
|
||||||
|
href={`/api/v1/files/${r.storageKey}/download?redirect=1` as Route}
|
||||||
|
>
|
||||||
<Download className="h-3.5 w-3.5" aria-hidden />
|
<Download className="h-3.5 w-3.5" aria-hidden />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
180
src/components/residential/residential-client-columns.tsx
Normal file
180
src/components/residential/residential-client-columns.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Mail, Phone } from 'lucide-react';
|
||||||
|
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { ColumnPickerOption } from '@/components/shared/column-picker';
|
||||||
|
|
||||||
|
export interface ResidentialClientRow {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
placeOfResidence: string | null;
|
||||||
|
status: string;
|
||||||
|
source: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
prospect: 'Prospect',
|
||||||
|
active: 'Active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column manifest for the residential clients list `<ColumnPicker>`.
|
||||||
|
* Mirrors the marina-side clients/interests pattern so the residential
|
||||||
|
* team gets the same show/hide affordance.
|
||||||
|
*/
|
||||||
|
export const RESIDENTIAL_CLIENT_COLUMN_OPTIONS: ColumnPickerOption[] = [
|
||||||
|
{ id: 'fullName', label: 'Name', alwaysVisible: true },
|
||||||
|
{ id: 'email', label: 'Email' },
|
||||||
|
{ id: 'phone', label: 'Phone' },
|
||||||
|
{ id: 'residence', label: 'Residence' },
|
||||||
|
{ id: 'status', label: 'Status' },
|
||||||
|
{ id: 'source', label: 'Source' },
|
||||||
|
{ id: 'createdAt', label: 'Date added' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Residence" is empty for nearly every residential client (we don't
|
||||||
|
* capture it at intake — it rendered as a column of "-"), so it's hidden
|
||||||
|
* by default and "Date added" takes its place. Users can re-enable
|
||||||
|
* Residence via the picker; their choice then persists.
|
||||||
|
*/
|
||||||
|
export const RESIDENTIAL_CLIENT_DEFAULT_HIDDEN: string[] = ['residence'];
|
||||||
|
|
||||||
|
export function getResidentialClientColumns({
|
||||||
|
portSlug,
|
||||||
|
}: {
|
||||||
|
portSlug: string;
|
||||||
|
}): ColumnDef<ResidentialClientRow, unknown>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'fullName',
|
||||||
|
accessorKey: 'fullName',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/residential/clients/${row.original.id}` as Route}
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{row.original.fullName}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
header: 'Email',
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const value = row.original.email;
|
||||||
|
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`mailto:${value}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary hover:underline"
|
||||||
|
title={`Email ${value}`}
|
||||||
|
>
|
||||||
|
<Mail className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="truncate">{value}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'phone',
|
||||||
|
header: 'Phone',
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const value = row.original.phone;
|
||||||
|
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||||
|
const waDigits = value.replace(/[^\d]/g, '');
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||||
|
<a
|
||||||
|
href={`tel:${value}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-1.5 text-foreground hover:text-primary hover:underline"
|
||||||
|
title={`Call ${value}`}
|
||||||
|
>
|
||||||
|
<Phone className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span>{value}</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`https://wa.me/${waDigits}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-emerald-600 hover:text-emerald-700"
|
||||||
|
title={`WhatsApp ${value}`}
|
||||||
|
aria-label={`WhatsApp ${value}`}
|
||||||
|
>
|
||||||
|
<WhatsAppIcon className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'residence',
|
||||||
|
accessorKey: 'placeOfResidence',
|
||||||
|
header: 'Residence',
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const v = getValue() as string | null;
|
||||||
|
return v ? (
|
||||||
|
<span className="text-muted-foreground">{v}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const s = getValue() as string;
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{STATUS_LABELS[s] ?? s}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'source',
|
||||||
|
accessorKey: 'source',
|
||||||
|
header: 'Source',
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const source = getValue() as string | null;
|
||||||
|
if (!source) return <span className="text-muted-foreground">-</span>;
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="capitalize text-xs">
|
||||||
|
{source}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'createdAt',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
header: 'Date added',
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{format(new Date(getValue() as string), 'MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import type { Route } from 'next';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -22,17 +22,15 @@ import { apiFetch } from '@/lib/api/client';
|
|||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { CountryCode } from '@/lib/i18n/countries';
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
|
import { DataTable } from '@/components/shared/data-table';
|
||||||
interface ResidentialClientRow {
|
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||||
id: string;
|
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||||
fullName: string;
|
import {
|
||||||
email: string | null;
|
getResidentialClientColumns,
|
||||||
phone: string | null;
|
RESIDENTIAL_CLIENT_COLUMN_OPTIONS,
|
||||||
placeOfResidence: string | null;
|
RESIDENTIAL_CLIENT_DEFAULT_HIDDEN,
|
||||||
status: string;
|
type ResidentialClientRow,
|
||||||
source: string | null;
|
} from '@/components/residential/residential-client-columns';
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ListResponse {
|
interface ListResponse {
|
||||||
data: ResidentialClientRow[];
|
data: ResidentialClientRow[];
|
||||||
@@ -48,6 +46,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
export function ResidentialClientsList() {
|
export function ResidentialClientsList() {
|
||||||
const params = useParams<{ portSlug: string }>();
|
const params = useParams<{ portSlug: string }>();
|
||||||
const portSlug = params?.portSlug ?? '';
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
const router = useRouter();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
@@ -66,6 +65,17 @@ export function ResidentialClientsList() {
|
|||||||
'residential_client:restored': [['residential-clients']],
|
'residential_client:restored': [['residential-clients']],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const columns = getResidentialClientColumns({ portSlug });
|
||||||
|
// Per-user column visibility, persisted via /api/v1/me — same hook + UX as
|
||||||
|
// the marina clients/interests lists. "Residence" is hidden by default
|
||||||
|
// (it's empty for nearly every residential client); "Date added" is shown.
|
||||||
|
const { hidden, setHidden } = useTablePreferences(
|
||||||
|
'residential-clients',
|
||||||
|
RESIDENTIAL_CLIENT_DEFAULT_HIDDEN,
|
||||||
|
);
|
||||||
|
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
|
||||||
|
const rows = data?.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -79,156 +89,82 @@ export function ResidentialClientsList() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by name, email, phone, residence…"
|
placeholder="Search by name, email, phone, residence…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="max-w-sm"
|
className="max-w-sm"
|
||||||
/>
|
/>
|
||||||
|
<ColumnPicker
|
||||||
|
columns={RESIDENTIAL_CLIENT_COLUMN_OPTIONS}
|
||||||
|
hidden={hidden}
|
||||||
|
onChange={setHidden}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop: table layout. Hidden below lg because the 6 columns clip
|
<DataTable
|
||||||
off the viewport at phone widths. */}
|
columns={columns}
|
||||||
<div className="hidden md:block rounded-lg border bg-card overflow-hidden">
|
columnVisibility={columnVisibility}
|
||||||
<table className="w-full text-sm">
|
data={rows}
|
||||||
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
isLoading={isLoading}
|
||||||
<tr>
|
getRowId={(r) => r.id}
|
||||||
<th className="text-left font-medium px-3 py-2">Name</th>
|
onRowClick={(r) => router.push(`/${portSlug}/residential/clients/${r.id}` as Route)}
|
||||||
<th className="text-left font-medium px-3 py-2">Email</th>
|
emptyState={
|
||||||
<th className="text-left font-medium px-3 py-2">Phone</th>
|
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||||
<th className="text-left font-medium px-3 py-2">Residence</th>
|
|
||||||
<th className="text-left font-medium px-3 py-2">Status</th>
|
|
||||||
<th className="text-left font-medium px-3 py-2">Source</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{isLoading && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-3 py-8 text-center text-muted-foreground">
|
|
||||||
Loading…
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{!isLoading && data?.data.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-3 py-8 text-center text-muted-foreground">
|
|
||||||
No residential clients yet.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{data?.data.map((c) => (
|
|
||||||
<tr
|
|
||||||
key={c.id}
|
|
||||||
className="border-t hover:bg-muted/30 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<Link
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
||||||
href={`/${portSlug}/residential/clients/${c.id}` as any}
|
|
||||||
className="font-medium hover:underline"
|
|
||||||
>
|
|
||||||
{c.fullName}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-muted-foreground">
|
|
||||||
{c.email ? (
|
|
||||||
<a
|
|
||||||
href={`mailto:${c.email}`}
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{c.email}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
'-'
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-muted-foreground">
|
|
||||||
{c.phone ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<a
|
|
||||||
href={`tel:${c.phone}`}
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{c.phone}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={`https://wa.me/${c.phone.replace(/[^\d+]/g, '')}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
title="WhatsApp"
|
|
||||||
aria-label="Message on WhatsApp"
|
|
||||||
className="text-emerald-600 hover:text-emerald-700"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<WhatsAppIcon className="h-3.5 w-3.5" />
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'-'
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-muted-foreground">{c.placeOfResidence ?? '-'}</td>
|
|
||||||
<td className="px-3 py-2">{STATUS_LABELS[c.status] ?? c.status}</td>
|
|
||||||
<td className="px-3 py-2 capitalize text-muted-foreground">{c.source ?? '-'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile: card list. Each card mirrors the table row data with
|
|
||||||
name + status pill on top, then meta line(s) below. */}
|
|
||||||
<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…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoading && data?.data.length === 0 && (
|
|
||||||
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
|
|
||||||
No residential clients yet.
|
No residential clients yet.
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
{data?.data.map((c) => (
|
cardRender={(row) => <ResidentialClientCard portSlug={portSlug} client={row.original} />}
|
||||||
<Link
|
/>
|
||||||
key={c.id}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
href={`/${portSlug}/residential/clients/${c.id}` as any}
|
|
||||||
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<p className="font-medium text-sm truncate">{c.fullName}</p>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium uppercase tracking-wide',
|
|
||||||
c.status === 'active'
|
|
||||||
? 'bg-emerald-100 text-emerald-800'
|
|
||||||
: c.status === 'inactive'
|
|
||||||
? 'bg-muted text-muted-foreground'
|
|
||||||
: 'bg-blue-100 text-blue-800',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{STATUS_LABELS[c.status] ?? c.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
|
||||||
{c.email ? <span className="truncate">{c.email}</span> : null}
|
|
||||||
{c.phone ? <span>{c.phone}</span> : null}
|
|
||||||
{c.placeOfResidence ? <span>{c.placeOfResidence}</span> : null}
|
|
||||||
{c.source ? <span className="capitalize">· {c.source}</span> : null}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} />
|
<NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile card for a residential client — DataTable swaps to this below the
|
||||||
|
* md breakpoint. Self-navigating `<Link>` (DataTable's onRowClick only wires
|
||||||
|
* the desktop table rows). Mirrors the marina-side card density.
|
||||||
|
*/
|
||||||
|
function ResidentialClientCard({
|
||||||
|
portSlug,
|
||||||
|
client,
|
||||||
|
}: {
|
||||||
|
portSlug: string;
|
||||||
|
client: ResidentialClientRow;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/residential/clients/${client.id}` as Route}
|
||||||
|
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="truncate text-sm font-medium">{client.fullName}</p>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium uppercase tracking-wide',
|
||||||
|
client.status === 'active'
|
||||||
|
? 'bg-emerald-100 text-emerald-800'
|
||||||
|
: client.status === 'inactive'
|
||||||
|
? 'bg-muted text-muted-foreground'
|
||||||
|
: 'bg-blue-100 text-blue-800',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[client.status] ?? client.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{client.email ? <span className="truncate">{client.email}</span> : null}
|
||||||
|
{client.phone ? <span>{client.phone}</span> : null}
|
||||||
|
{client.placeOfResidence ? <span>{client.placeOfResidence}</span> : null}
|
||||||
|
{client.source ? <span className="capitalize">· {client.source}</span> : null}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NewResidentialClientSheet({
|
function NewResidentialClientSheet({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{completedAgreement.signedFileId ? (
|
{completedAgreement.signedFileId ? (
|
||||||
<Button asChild size="sm" variant="outline">
|
<Button asChild size="sm" variant="outline">
|
||||||
<Link href={`/api/v1/files/${completedAgreement.signedFileId}/download`}>
|
<Link href={`/api/v1/files/${completedAgreement.signedFileId}/download?redirect=1`}>
|
||||||
<Download className="mr-1.5 h-4 w-4" aria-hidden /> Download signed PDF
|
<Download className="mr-1.5 h-4 w-4" aria-hidden /> Download signed PDF
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user