feat(uat-batch-5): UI polish — dialog width, chart centering, recommender pill, audit link, inbox reorder
Six surgical Wave-2-3 wins:
- UploadForSigningDialog: dialog widened to max-w-[1400px] w-[95vw] so
the place-fields step actually has room; recipient row converts from
fixed grid to flex (name flex-1, email flex-[2] for the longer
string, role w-40, delete shrink-0); invitation-message textarea
rows 3 → 6.
- ChartCard becomes flex-col with flex-1 + items-center on CardContent
so charts vertically center when neighbouring cards make the row
taller (e.g. Pipeline Value's full breakdown).
- Berth recommender pill: drops the "Tier {letter} · " prefix; shows
just the plain-English label ("Open" / "Fall-through" / "Active
interest" / "Late stage") as a Popover trigger that explains the
4-state ladder. HelpCircle icon makes the tooltip discoverable.
- Activity feed gains a "See all" link in the header pointing at
/<port>/admin/audit, permission-gated by `admin.view_audit_log`.
- Inbox section order swaps to Reminders above Alerts (rep-noted
priority); PageHeader title flips to "Reminders & Alerts". Section
ids, deep-link hashes, and localStorage open-state keys untouched.
- Inbox ReminderList (embedded mode only): "New Reminder" button now
shares the filter row (right-aligned via ml-auto) instead of
occupying its own dedicated row above the filters.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
import {
|
import {
|
||||||
STAGE_LABELS,
|
STAGE_LABELS,
|
||||||
@@ -171,6 +174,11 @@ function ActionBadge({ action }: { action: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ActivityFeedInner() {
|
function ActivityFeedInner() {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const canViewAuditLog = can('admin', 'view_audit_log');
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<ActivityItem[]>({
|
const { data, isLoading } = useQuery<ActivityItem[]>({
|
||||||
queryKey: ['dashboard', 'activity'],
|
queryKey: ['dashboard', 'activity'],
|
||||||
queryFn: () => apiFetch<ActivityItem[]>('/api/v1/dashboard/activity'),
|
queryFn: () => apiFetch<ActivityItem[]>('/api/v1/dashboard/activity'),
|
||||||
@@ -190,8 +198,17 @@ function ActivityFeedInner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||||
<CardTitle className="text-base">Recent Activity</CardTitle>
|
<CardTitle className="text-base">Recent Activity</CardTitle>
|
||||||
|
{canViewAuditLog && portSlug ? (
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/admin/audit` as any}
|
||||||
|
className="text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
See all
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function ChartCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn('h-full', className)}>
|
<Card className={cn('h-full flex flex-col', className)}>
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">{title}</CardTitle>
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
@@ -116,8 +116,10 @@ export function ChartCard({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex-1 flex items-center justify-center">
|
||||||
<div ref={containerRef}>{children}</div>
|
<div ref={containerRef} className="w-full">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export function UploadForSigningDialog({
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden p-0 flex flex-col">
|
<DialogContent className="max-w-[1400px] w-[95vw] max-h-[90vh] overflow-hidden p-0 flex flex-col">
|
||||||
<DialogBody
|
<DialogBody
|
||||||
key={`${interestId}:${documentType}`}
|
key={`${interestId}:${documentType}`}
|
||||||
interestId={interestId}
|
interestId={interestId}
|
||||||
@@ -634,18 +634,18 @@ function RecipientsStep({
|
|||||||
<Label>Recipients (in signing order)</Label>
|
<Label>Recipients (in signing order)</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{recipients.map((r, i) => (
|
{recipients.map((r, i) => (
|
||||||
<div key={i} className="grid grid-cols-12 gap-2 items-center">
|
<div key={i} className="flex gap-2 items-center">
|
||||||
<span className="col-span-1 text-xs text-center text-muted-foreground tabular-nums">
|
<span className="w-8 shrink-0 text-xs text-center text-muted-foreground tabular-nums">
|
||||||
#{r.signingOrder}
|
#{r.signingOrder}
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
className="col-span-3"
|
className="flex-1 min-w-0"
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
value={r.name}
|
value={r.name}
|
||||||
onChange={(e) => update(i, { name: e.target.value })}
|
onChange={(e) => update(i, { name: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
className="col-span-4"
|
className="flex-[2] min-w-0"
|
||||||
placeholder="email@example.com"
|
placeholder="email@example.com"
|
||||||
type="email"
|
type="email"
|
||||||
value={r.email}
|
value={r.email}
|
||||||
@@ -655,7 +655,7 @@ function RecipientsStep({
|
|||||||
value={r.role}
|
value={r.role}
|
||||||
onValueChange={(v) => update(i, { role: v as Recipient['role'] })}
|
onValueChange={(v) => update(i, { role: v as Recipient['role'] })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="col-span-3">
|
<SelectTrigger className="w-40 shrink-0">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -670,7 +670,7 @@ function RecipientsStep({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => remove(i)}
|
onClick={() => remove(i)}
|
||||||
aria-label="Remove recipient"
|
aria-label="Remove recipient"
|
||||||
className="col-span-1"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" aria-hidden />
|
<Trash2 className="size-4" aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -689,8 +689,8 @@ function RecipientsStep({
|
|||||||
id="invitation-message"
|
id="invitation-message"
|
||||||
value={invitationMessage}
|
value={invitationMessage}
|
||||||
onChange={(e) => onInvitationMessageChange(e.target.value)}
|
onChange={(e) => onInvitationMessageChange(e.target.value)}
|
||||||
placeholder="Hi John — please review the attached contract before signing. Reach out if anything needs adjusting."
|
placeholder="Hi John, please review the attached contract before signing. Reach out if anything needs adjusting."
|
||||||
rows={3}
|
rows={6}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none"
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -75,12 +75,26 @@ export function InboxPageShell() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Alerts & Reminders"
|
title="Reminders & Alerts"
|
||||||
eyebrow="Action items"
|
eyebrow="Action items"
|
||||||
description="Alerts the system has flagged plus your scheduled follow-ups, in one place."
|
description="Your scheduled follow-ups plus alerts the system has flagged, in one place."
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<section id="inbox-section-reminders" className="rounded-lg border bg-card shadow-xs">
|
||||||
|
<SectionHeader
|
||||||
|
icon={<Bell className="size-4 text-muted-foreground" aria-hidden />}
|
||||||
|
label="Reminders"
|
||||||
|
open={remindersOpen}
|
||||||
|
onToggle={toggleReminders}
|
||||||
|
/>
|
||||||
|
{remindersOpen ? (
|
||||||
|
<div className="border-t px-4 pb-4 pt-3">
|
||||||
|
<ReminderList embedded />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
|
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
|
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
|
||||||
@@ -95,20 +109,6 @@ export function InboxPageShell() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="inbox-section-reminders" className="rounded-lg border bg-card shadow-xs">
|
|
||||||
<SectionHeader
|
|
||||||
icon={<Bell className="size-4 text-muted-foreground" aria-hidden />}
|
|
||||||
label="Reminders"
|
|
||||||
open={remindersOpen}
|
|
||||||
onToggle={toggleReminders}
|
|
||||||
/>
|
|
||||||
{remindersOpen ? (
|
|
||||||
<div className="border-t px-4 pb-4 pt-3">
|
|
||||||
<ReminderList embedded />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,22 @@ import { useState, useMemo } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ChevronDown, ChevronUp, Filter, Flame, Plus, RefreshCw, Sparkles } from 'lucide-react';
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Filter,
|
||||||
|
Flame,
|
||||||
|
HelpCircle,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -172,14 +182,42 @@ function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) {
|
|||||||
<span className="font-semibold">{rec.mooringNumber}</span>
|
<span className="font-semibold">{rec.mooringNumber}</span>
|
||||||
{rec.area ? <span className="text-xs text-muted-foreground">{rec.area}</span> : null}
|
{rec.area ? <span className="text-xs text-muted-foreground">{rec.area}</span> : null}
|
||||||
<StatusPill status={statusToPill(rec.status)}>{formatStatus(rec.status)}</StatusPill>
|
<StatusPill status={statusToPill(rec.status)}>{formatStatus(rec.status)}</StatusPill>
|
||||||
<span
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
|
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
tier.tone,
|
tier.tone,
|
||||||
)}
|
)}
|
||||||
|
aria-label={`Recommender state: ${tier.label}`}
|
||||||
>
|
>
|
||||||
Tier {rec.tier} · {tier.label}
|
{tier.label}
|
||||||
</span>
|
<HelpCircle className="size-3 opacity-60" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
|
||||||
|
<p className="font-medium text-foreground">Recommender state</p>
|
||||||
|
<ul className="mt-2 space-y-1.5 text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-emerald-700">Open</span>: never had an
|
||||||
|
interest, ready for new prospects.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-amber-700">Fall-through</span>: a prior
|
||||||
|
interest didn't close; warm and worth pitching again.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-sky-700">Active interest</span>: another deal
|
||||||
|
is in play. Coordinate before pitching.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-slate-700">Late stage</span>: another deal is
|
||||||
|
near-sold; treat as backup only.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
{showHeat ? (
|
{showHeat ? (
|
||||||
<span className="inline-flex items-center gap-1 rounded-md border border-rose-200 bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-800">
|
<span className="inline-flex items-center gap-1 rounded-md border border-rose-200 bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-800">
|
||||||
<Flame className="size-3" aria-hidden />
|
<Flame className="size-3" aria-hidden />
|
||||||
|
|||||||
@@ -295,23 +295,12 @@ export function ReminderList({ embedded = false }: ReminderListProps = {}) {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : null}
|
||||||
<div className="mb-3 flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingReminder(null);
|
|
||||||
setFormOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1.5 h-4 w-4" aria-hidden />
|
|
||||||
New Reminder
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Wrap on phone widths so the priority filter doesn't get pushed
|
{/* Wrap on phone widths so the priority filter doesn't get pushed
|
||||||
off-screen by the My/All tabs + status filter taking the full row. */}
|
off-screen by the My/All tabs + status filter taking the full row.
|
||||||
|
In embedded mode, the "New Reminder" button shares this row
|
||||||
|
(right-aligned via ml-auto) so filters + CTA stay visually tight. */}
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-4 sm:gap-4">
|
<div className="flex flex-wrap items-center gap-3 mb-4 sm:gap-4">
|
||||||
{canViewAll && (
|
{canViewAll && (
|
||||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'my' | 'all')}>
|
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'my' | 'all')}>
|
||||||
@@ -350,6 +339,20 @@ export function ReminderList({ embedded = false }: ReminderListProps = {}) {
|
|||||||
<SelectItem value="low">Low</SelectItem>
|
<SelectItem value="low">Low</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{embedded ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingReminder(null);
|
||||||
|
setFormOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
New Reminder
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
Reference in New Issue
Block a user