feat(uat-batch-12): password-reveal env messaging + berth Latest-stage sortable

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 18:11:17 +02:00
parent 901fc363a5
commit ca51000401
3 changed files with 58 additions and 3 deletions

View File

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