feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones
Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width <sm - contacts row restructured so Primary toggle + Remove get their own strip - customize-dashboard footer stacks vertically on mobile; Done full-width - interest-form client/berth pickers no longer cmdk-filter on UUID (typing "Carlos" now returns Carlos Vega instead of "No clients found") Data + consistency - SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces now resolve interest/client source from one place - INTEREST_OUTCOMES adds lost_other (picker, badge, timeline) - Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort - archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles - TableBody last-row uses border-b-0 (not border-0); colored left-accent on the bottom berth row now renders - Hide Invite-to-Portal until port setting === true (was !== false default-show) - OwnerPicker primer query resolves entity name on first paint (no more UUID flash before the popover opens) Terminology - Replaced user-facing "Documenso" with "signing service" / "Generated EOI" / "Manual EOI" in 8 components (admin/internal references kept) - Plainer status-change copy on berth-detail-header Forms + editing - InlineEditableField gained a `date` variant (native picker); applied to company incorporation date and ready for other YYYY-MM-DD plaintext fields - Inline source picker on interest-tabs detail (was free text) - TagPicker self-hides when port has no tags AND nothing is selected - New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom) - Compose dialog follow-up is now a toggle that reveals datetime picker Pipeline milestones - changeStageSchema accepts optional milestoneDate; service stamps it on the matching date column instead of always using now - MilestoneAdvanceButton popover collects a back-date before stage advance - Applied to every "Mark X manually" surface on the interest overview EOI / linked-berths polish - Add-bypass row aligned inline with toggle descriptions - Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their legal vs. public-map consequences Surfaces - Companies list now has the column picker + persisted hidden-column prefs - NotesList aggregate flag enabled on clients, companies, residential_clients (yachts already aggregated) ft/m unit toggle (interim, before drift fix) - "Berth size desired" gets a section-level ft/m toggle; per-field hint shows the converted value. Storage stays canonical-ft for now; the drift-safe persistence migration is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
import { and, count, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { and, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { companies } from '@/lib/db/schema/companies';
|
||||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { invoices, expenses } from '@/lib/db/schema/financial';
|
||||
import { documents } from '@/lib/db/schema/documents';
|
||||
import { reminders } from '@/lib/db/schema/operations';
|
||||
import { systemSettings, auditLogs } from '@/lib/db/schema/system';
|
||||
import { PIPELINE_STAGES, STAGE_WEIGHTS } from '@/lib/constants';
|
||||
|
||||
@@ -156,6 +161,119 @@ export async function getRevenueForecast(portId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Compact widget queries ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Berth status split for the donut widget. Returns counts plus the total
|
||||
* so the chart can show "12 of 47 sold" alongside the segment percentage.
|
||||
*/
|
||||
export async function getBerthStatusDistribution(portId: string) {
|
||||
const rows = await db
|
||||
.select({ status: berths.status, c: sql<number>`count(*)::int` })
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, portId))
|
||||
.groupBy(berths.status);
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const r of rows) counts[r.status] = r.c;
|
||||
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
available: counts['available'] ?? 0,
|
||||
underOffer: counts['under_offer'] ?? 0,
|
||||
sold: counts['sold'] ?? 0,
|
||||
maintenance: counts['maintenance'] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Top 5 active interests closest to closing — ranked by pipeline stage
|
||||
* (further = closer to closing) with most-recent activity as a
|
||||
* tiebreaker. Surfaces the deals reps should actually be chasing on the
|
||||
* dashboard without making them open the pipeline board.
|
||||
*/
|
||||
export async function getHotDeals(portId: string, limit = 5) {
|
||||
// Stage rank: bigger = closer to closing.
|
||||
const rank = sql<number>`CASE ${interests.pipelineStage}
|
||||
WHEN 'completed' THEN 8
|
||||
WHEN 'contract_signed' THEN 7
|
||||
WHEN 'contract_sent' THEN 6
|
||||
WHEN 'deposit_10' THEN 5
|
||||
WHEN 'eoi_signed' THEN 4
|
||||
WHEN 'eoi_sent' THEN 3
|
||||
WHEN 'in_comms' THEN 2
|
||||
WHEN 'details_sent' THEN 1
|
||||
ELSE 0
|
||||
END`;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: interests.id,
|
||||
stage: interests.pipelineStage,
|
||||
clientName: clients.fullName,
|
||||
mooring: berths.mooringNumber,
|
||||
lastContact: interests.dateLastContact,
|
||||
updatedAt: interests.updatedAt,
|
||||
rank,
|
||||
})
|
||||
.from(interests)
|
||||
.innerJoin(clients, eq(interests.clientId, clients.id))
|
||||
.leftJoin(
|
||||
interestBerths,
|
||||
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
||||
)
|
||||
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
isNull(interests.archivedAt),
|
||||
isNull(interests.outcome), // exclude won/lost — they're not "closing"
|
||||
),
|
||||
)
|
||||
.orderBy(desc(rank), desc(interests.updatedAt))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
stage: r.stage,
|
||||
clientName: r.clientName,
|
||||
mooringNumber: r.mooring,
|
||||
lastContact: r.lastContact ? r.lastContact.toISOString() : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-conversion breakdown for the marketing widget. Returns per-
|
||||
* source totals (active + won + lost) and a derived conversion rate so
|
||||
* reps see which channels deliver buyers vs tire-kickers — orthogonal
|
||||
* to the existing "lead source attribution" chart which only counts
|
||||
* inbound volume.
|
||||
*/
|
||||
export async function getSourceConversion(portId: string) {
|
||||
const rows = await db
|
||||
.select({
|
||||
source: interests.source,
|
||||
total: sql<number>`count(*)::int`,
|
||||
won: sql<number>`sum(case when ${interests.outcome} = 'won' then 1 else 0 end)::int`,
|
||||
lost: sql<number>`sum(case when ${interests.outcome} = 'lost' then 1 else 0 end)::int`,
|
||||
})
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
|
||||
.groupBy(interests.source);
|
||||
|
||||
return rows
|
||||
.filter((r) => r.source)
|
||||
.map((r) => ({
|
||||
source: r.source!,
|
||||
total: r.total,
|
||||
won: r.won,
|
||||
lost: r.lost,
|
||||
conversionRate: r.total > 0 ? r.won / r.total : 0,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
}
|
||||
|
||||
// ─── Recent Activity ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function getRecentActivity(portId: string, limit = 20) {
|
||||
@@ -166,6 +284,9 @@ export async function getRecentActivity(portId: string, limit = 20) {
|
||||
entityType: auditLogs.entityType,
|
||||
entityId: auditLogs.entityId,
|
||||
userId: auditLogs.userId,
|
||||
fieldChanged: auditLogs.fieldChanged,
|
||||
oldValue: auditLogs.oldValue,
|
||||
newValue: auditLogs.newValue,
|
||||
metadata: auditLogs.metadata,
|
||||
createdAt: auditLogs.createdAt,
|
||||
})
|
||||
@@ -174,5 +295,121 @@ export async function getRecentActivity(portId: string, limit = 20) {
|
||||
.orderBy(desc(auditLogs.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return rows;
|
||||
// Resolve a human label per row (client name, yacht name, invoice number,
|
||||
// …). The dashboard widget previously rendered the bare UUID prefix which
|
||||
// told reps nothing about which entity was touched. We batch one SELECT
|
||||
// per entityType, capping at the row set's natural size (<= `limit`).
|
||||
const byType = new Map<string, Set<string>>();
|
||||
for (const r of rows) {
|
||||
if (!r.entityId) continue;
|
||||
if (!byType.has(r.entityType)) byType.set(r.entityType, new Set());
|
||||
byType.get(r.entityType)!.add(r.entityId);
|
||||
}
|
||||
|
||||
const labels = new Map<string, string>(); // `${type}:${id}` → label
|
||||
|
||||
async function loadLabels<T extends { id: string }>(
|
||||
type: string,
|
||||
fetcher: (ids: string[]) => Promise<T[]>,
|
||||
pick: (row: T) => string,
|
||||
) {
|
||||
const ids = Array.from(byType.get(type) ?? []);
|
||||
if (ids.length === 0) return;
|
||||
const fetched = await fetcher(ids);
|
||||
for (const row of fetched) labels.set(`${type}:${row.id}`, pick(row));
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadLabels(
|
||||
'client',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: clients.id, name: clients.fullName })
|
||||
.from(clients)
|
||||
.where(and(eq(clients.portId, portId), inArray(clients.id, ids))),
|
||||
(r) => r.name,
|
||||
),
|
||||
loadLabels(
|
||||
'yacht',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: yachts.id, name: yachts.name })
|
||||
.from(yachts)
|
||||
.where(and(eq(yachts.portId, portId), inArray(yachts.id, ids))),
|
||||
(r) => r.name,
|
||||
),
|
||||
loadLabels(
|
||||
'company',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: companies.id, name: companies.name })
|
||||
.from(companies)
|
||||
.where(and(eq(companies.portId, portId), inArray(companies.id, ids))),
|
||||
(r) => r.name,
|
||||
),
|
||||
loadLabels(
|
||||
'interest',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: interests.id, clientName: clients.fullName })
|
||||
.from(interests)
|
||||
.innerJoin(clients, eq(interests.clientId, clients.id))
|
||||
.where(and(eq(interests.portId, portId), inArray(interests.id, ids))),
|
||||
(r) => r.clientName,
|
||||
),
|
||||
loadLabels(
|
||||
'berth',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: berths.id, mooring: berths.mooringNumber })
|
||||
.from(berths)
|
||||
.where(and(eq(berths.portId, portId), inArray(berths.id, ids))),
|
||||
(r) => `Berth ${r.mooring}`,
|
||||
),
|
||||
loadLabels(
|
||||
'invoice',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: invoices.id, num: invoices.invoiceNumber })
|
||||
.from(invoices)
|
||||
.where(and(eq(invoices.portId, portId), inArray(invoices.id, ids))),
|
||||
(r) => r.num,
|
||||
),
|
||||
loadLabels(
|
||||
'expense',
|
||||
(ids) =>
|
||||
db
|
||||
.select({
|
||||
id: expenses.id,
|
||||
desc: expenses.description,
|
||||
vendor: expenses.establishmentName,
|
||||
})
|
||||
.from(expenses)
|
||||
.where(and(eq(expenses.portId, portId), inArray(expenses.id, ids))),
|
||||
(r) => r.desc ?? r.vendor ?? 'Expense',
|
||||
),
|
||||
loadLabels(
|
||||
'document',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: documents.id, title: documents.title })
|
||||
.from(documents)
|
||||
.where(and(eq(documents.portId, portId), inArray(documents.id, ids))),
|
||||
(r) => r.title,
|
||||
),
|
||||
loadLabels(
|
||||
'reminder',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: reminders.id, title: reminders.title })
|
||||
.from(reminders)
|
||||
.where(and(eq(reminders.portId, portId), inArray(reminders.id, ids))),
|
||||
(r) => r.title,
|
||||
),
|
||||
]);
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
label: r.entityId ? (labels.get(`${r.entityType}:${r.entityId}`) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user