From ca510004011d1fae28ed26545366804501cdee37 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 18:11:17 +0200 Subject: [PATCH] feat(uat-batch-12): password-reveal env messaging + berth Latest-stage sortable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry-driven-form password-reveal eye toggle: when the value is resolved from env / default fallback (not port / global override), the toggle is now disabled with a tooltip explaining "Value comes from the environment. Configure in admin to enable reveal." Stops the silent-no-op confusion that read as a broken toggle. - Berth list: 'Latest deal stage' column dropped enableSorting:false. Service-side adds a stageSort correlated subquery that ranks each berth by the highest active interest's pipelineStage (enquiry=1 → contract=7); NULLS LAST regardless of direction so empty rows always land at the bottom. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/shared/registry-driven-form.tsx | 22 ++++++++++- src/components/berths/berth-columns.tsx | 2 +- src/lib/services/berths.service.ts | 37 ++++++++++++++++++- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/components/admin/shared/registry-driven-form.tsx b/src/components/admin/shared/registry-driven-form.tsx index 9be30e25..b1e1ff55 100644 --- a/src/components/admin/shared/registry-driven-form.tsx +++ b/src/components/admin/shared/registry-driven-form.tsx @@ -436,7 +436,27 @@ function SettingField({ type="button" variant="ghost" size="sm" - disabled={reveal.isPending} + disabled={ + reveal.isPending || + // Disable when the value is resolved from env/default and the + // rep hasn't typed anything yet — there's no in-app cleartext + // path for those, and silently no-op'ing was indistinguishable + // from a broken toggle. + (!showSecret && + resolved?.isSet === true && + (resolved?.source === 'env' || resolved?.source === 'default') && + !(typeof draft === 'string' && draft.length > 0)) + } + title={ + !showSecret && + resolved?.isSet === true && + (resolved?.source === 'env' || resolved?.source === 'default') && + !(typeof draft === 'string' && draft.length > 0) + ? 'Value comes from the environment. Configure in admin to enable reveal.' + : showSecret + ? 'Hide value' + : 'Reveal value' + } onClick={() => { if (showSecret) { // Hide. If this draft came from the server reveal, drop it so diff --git a/src/components/berths/berth-columns.tsx b/src/components/berths/berth-columns.tsx index 2f700d9b..92a2ae86 100644 --- a/src/components/berths/berth-columns.tsx +++ b/src/components/berths/berth-columns.tsx @@ -273,7 +273,7 @@ export const berthColumns: ColumnDef[] = [ { id: 'latestInterestStage', header: 'Latest deal stage', - enableSorting: false, + enableSorting: true, cell: ({ row }) => { const s = row.original.latestInterestStage; if (!s) return -; diff --git a/src/lib/services/berths.service.ts b/src/lib/services/berths.service.ts index 101bc924..f9072a16 100644 --- a/src/lib/services/berths.service.ts +++ b/src/lib/services/berths.service.ts @@ -94,6 +94,11 @@ export async function listBerths(portId: string, query: ListBerthsQuery) { case 'activeInterestCount': // Sorted via correlated subquery in customOrderBy below. return null; + case 'latestInterestStage': + // Sorted via correlated subquery in customOrderBy below — the + // column doesn't exist on berths; it's the highest-ranked + // active interest's pipeline stage per berth. + return null; default: // No sort requested → natural mooring order is the friendliest // default for the berth grid (groups by pontoon letter). @@ -119,6 +124,36 @@ export async function listBerths(portId: string, query: ListBerthsQuery) { ] : null; + // Sort by highest active pipeline stage per berth. Berths with no + // active interest get NULL; we land them at the bottom regardless of + // direction by paired ORDER BY rank + NULLS LAST. + const stageDirection = query.order === 'asc' ? 'ASC' : 'DESC'; + const stageSort = + query.sort === 'latestInterestStage' + ? [ + sql`( + SELECT MAX( + CASE i.pipeline_stage + WHEN 'enquiry' THEN 1 + WHEN 'qualified' THEN 2 + WHEN 'nurturing' THEN 3 + WHEN 'eoi' THEN 4 + WHEN 'reservation' THEN 5 + WHEN 'deposit_paid' THEN 6 + WHEN 'contract' THEN 7 + ELSE 0 + END + ) + FROM ${interestBerths} ib + INNER JOIN ${interests} i ON i.id = ib.interest_id + WHERE ib.berth_id = ${berths.id} + AND i.port_id = ${portId} + AND i.archived_at IS NULL + AND i.outcome IS NULL + ) ${sql.raw(stageDirection)} NULLS LAST`, + ] + : null; + const result = await buildListQuery({ table: berths, portIdColumn: berths.portId, @@ -127,7 +162,7 @@ export async function listBerths(portId: string, query: ListBerthsQuery) { updatedAtColumn: berths.updatedAt, filters, sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined, - customOrderBy: demandSort ?? (sortColumn ? undefined : NATURAL_MOORING_SORT), + customOrderBy: stageSort ?? demandSort ?? (sortColumn ? undefined : NATURAL_MOORING_SORT), page: query.page, pageSize: query.limit, searchColumns: [berths.mooringNumber, berths.area],