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],