fix(uat): batch — timeline overshoot, name-sync, reset-password, dashboard cleanup, queue/seed hygiene + alpha UAT findings doc
UAT findings landed across the last few Playwright + React Grab passes; single grouped commit so the index doesn't fragment into 30 one-liners. User & auth: - `user-settings`: name now updates the avatar + topbar menu after save (was reading stale session). - `me/password-reset`: 3 bugs (token validation, error response shape, redirect chain). - Admin user permission-overrides route honours the same envelope as the rest of the admin surface. Dashboard: - Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card` (replaced by the customisable widget grid). - Strip `revenue_breakdown` from analytics route + use-analytics + service + integration test so nothing renders an empty card. - Activity log timeline overshoot fix (`interest-timeline` + `entity-activity-feed`). - Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile. - `dev-mode-banner`: derive dismissed state synchronously instead of via an effect (set-state-in-effect lint rule). Forms & lists (assorted polish): - client / company / yacht / interest / reminder forms — validation + empty-state copy + tab transitions. - companies/yachts list tweaks; berth recommender panel; qualification checklist; supplemental info request button. Infra & misc: - Queue workers (ai / email / notifications) — log shape + per-job timeout consistency. - Auth / brochures / users schema small adjustments; seeds reflect permissions matrix changes. - Scan shell + scanner manifest + AI admin page small fixes. - `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react` (recommended config from echarts-for-react inside Next). Docs: - `docs/superpowers/audits/alpha-uat-master.md` — single rolling cross-cutting UAT findings doc (per CLAUDE.md convention). - `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log normalization (§J). - 2026-05-18 audit log updated with this batch. - `CLAUDE.md` — small manual UAT scaffold notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,35 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
import { Bot, FileText, Brain, ExternalLink } from 'lucide-react';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
|
||||
|
||||
interface FeatureLink {
|
||||
href: string;
|
||||
icon: typeof Bot;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const FEATURE_LINKS: FeatureLink[] = [
|
||||
{
|
||||
href: '../berth-pdf-parser',
|
||||
icon: FileText,
|
||||
title: 'Berth PDF parser',
|
||||
description:
|
||||
'Three-tier AcroForm → OCR → AI pipeline. The AI pass costs tokens; reps invoke it manually when OCR confidence is low.',
|
||||
},
|
||||
{
|
||||
href: '../recommender',
|
||||
icon: Brain,
|
||||
title: 'Berth recommender',
|
||||
description:
|
||||
'Rule-based today; future versions will optionally use embeddings for soft preference matching. AI use is gated by the master switch above.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AiAdminPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -65,34 +40,6 @@ export default function AiAdminPage() {
|
||||
<OcrSettingsForm embedded />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" /> Per-feature settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Feature-specific tuning lives on each feature's admin page. They all read the
|
||||
master switch + provider credentials configured above.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURE_LINKS.map((f) => (
|
||||
<Link
|
||||
key={f.href}
|
||||
href={f.href as never}
|
||||
className="rounded-md border bg-card p-3 hover:border-primary transition-colors block"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<f.icon className="h-4 w-4 text-muted-foreground" />
|
||||
{f.title}
|
||||
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{f.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ports } from '@/lib/db/schema/ports';
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ portSlug: string }> }) {
|
||||
const { portSlug } = await params;
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
||||
const portName = port?.name ?? 'Port Nimara';
|
||||
const portName = port?.name ?? 'CRM';
|
||||
|
||||
const manifest = {
|
||||
name: `${portName} - Scanner`,
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { ScanShell } from '@/components/scan/scan-shell';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Scan receipt - Port Nimara',
|
||||
title: 'Scan receipt',
|
||||
};
|
||||
|
||||
export default function ScanPage() {
|
||||
return <ScanShell />;
|
||||
export default async function ScanPage({ params }: { params: Promise<{ portSlug: string }> }) {
|
||||
const { portSlug } = await params;
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
||||
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
|
||||
return <ScanShell logoUrl={branding?.logoUrl ?? null} portName={port?.name ?? null} />;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/lib/email/templates/residential-inquiry';
|
||||
import { resolveSubject } from '@/lib/email/resolve-subject';
|
||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
|
||||
import { env } from '@/lib/env';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||
@@ -142,12 +143,22 @@ async function sendResidentialNotifications(args: {
|
||||
}): Promise<void> {
|
||||
const { portId, data, crmDeepLink } = args;
|
||||
|
||||
const branding = await getBrandingShell(portId);
|
||||
const [branding, portBrand, emailCfg] = await Promise.all([
|
||||
getBrandingShell(portId),
|
||||
getPortBrandingConfig(portId).catch(() => null),
|
||||
getPortEmailConfig(portId).catch(() => null),
|
||||
]);
|
||||
// Use the port's configured From address (or branding app name) for
|
||||
// the "contact us" line on the confirmation email so other ports don't
|
||||
// direct replies to sales@portnimara.com.
|
||||
const contactEmail = emailCfg?.fromAddress ?? '';
|
||||
const portName = portBrand?.appName ?? 'our team';
|
||||
|
||||
// Client confirmation
|
||||
const confirmation = await residentialClientConfirmation(
|
||||
{
|
||||
firstName: data.firstName,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
contactEmail,
|
||||
},
|
||||
{ branding },
|
||||
);
|
||||
@@ -155,7 +166,7 @@ async function sendResidentialNotifications(args: {
|
||||
key: 'residential_inquiry_client_confirmation',
|
||||
portId,
|
||||
fallback: confirmation.subject,
|
||||
tokens: { portName: 'Port Nimara', recipientName: data.firstName },
|
||||
tokens: { portName, recipientName: data.firstName },
|
||||
});
|
||||
await sendEmail(data.email, confirmationSubject, confirmation.html, undefined, undefined, portId);
|
||||
|
||||
@@ -204,7 +215,7 @@ async function sendResidentialNotifications(args: {
|
||||
portId,
|
||||
fallback: alert.subject,
|
||||
tokens: {
|
||||
portName: 'Port Nimara',
|
||||
portName,
|
||||
clientName: `${data.firstName} ${data.lastName}`.trim(),
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
|
||||
@@ -46,7 +46,7 @@ const ALLOWED_RESOURCE_ACTIONS: Record<string, Set<string>> = {
|
||||
'generate_eoi',
|
||||
'export',
|
||||
]),
|
||||
berths: new Set(['view', 'edit', 'import', 'manage_waiting_list']),
|
||||
berths: new Set(['view', 'edit', 'import', 'manage_waiting_list', 'update_prices']),
|
||||
documents: new Set([
|
||||
'view',
|
||||
'create',
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getLeadSourceAttribution,
|
||||
getOccupancyTimeline,
|
||||
getPipelineFunnel,
|
||||
getRevenueBreakdown,
|
||||
type DateRange,
|
||||
type MetricBase,
|
||||
type PresetDateRange,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
|
||||
pipeline_funnel: getPipelineFunnel,
|
||||
occupancy_timeline: getOccupancyTimeline,
|
||||
revenue_breakdown: getRevenueBreakdown,
|
||||
lead_source_attribution: getLeadSourceAttribution,
|
||||
};
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ export const POST = withAuth(
|
||||
|
||||
await sendSigningInvitation({
|
||||
portId: ctx.portId,
|
||||
portName: port?.name ?? 'Port Nimara',
|
||||
portName: port?.name ?? 'the marina',
|
||||
recipient: { name: target.signerName, email: target.signerEmail },
|
||||
documensoSigningUrl: target.signingUrl,
|
||||
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
||||
|
||||
@@ -20,7 +20,9 @@ export const POST = withAuth(async (_req, ctx) => {
|
||||
await auth.api.requestPasswordReset({
|
||||
body: {
|
||||
email: ctx.user.email,
|
||||
redirectTo: '/reset-password',
|
||||
// /set-password is the form that actually consumes the token.
|
||||
// /reset-password is the "request a link" entry point.
|
||||
redirectTo: '/set-password',
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
Reference in New Issue
Block a user