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:
@@ -436,7 +436,27 @@ function SettingField({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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={() => {
|
onClick={() => {
|
||||||
if (showSecret) {
|
if (showSecret) {
|
||||||
// Hide. If this draft came from the server reveal, drop it so
|
// Hide. If this draft came from the server reveal, drop it so
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
|||||||
{
|
{
|
||||||
id: 'latestInterestStage',
|
id: 'latestInterestStage',
|
||||||
header: 'Latest deal stage',
|
header: 'Latest deal stage',
|
||||||
enableSorting: false,
|
enableSorting: true,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const s = row.original.latestInterestStage;
|
const s = row.original.latestInterestStage;
|
||||||
if (!s) return <span className="text-muted-foreground">-</span>;
|
if (!s) return <span className="text-muted-foreground">-</span>;
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
|||||||
case 'activeInterestCount':
|
case 'activeInterestCount':
|
||||||
// Sorted via correlated subquery in customOrderBy below.
|
// Sorted via correlated subquery in customOrderBy below.
|
||||||
return null;
|
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:
|
default:
|
||||||
// No sort requested → natural mooring order is the friendliest
|
// No sort requested → natural mooring order is the friendliest
|
||||||
// default for the berth grid (groups by pontoon letter).
|
// default for the berth grid (groups by pontoon letter).
|
||||||
@@ -119,6 +124,36 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
|||||||
]
|
]
|
||||||
: null;
|
: 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({
|
const result = await buildListQuery({
|
||||||
table: berths,
|
table: berths,
|
||||||
portIdColumn: berths.portId,
|
portIdColumn: berths.portId,
|
||||||
@@ -127,7 +162,7 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
|||||||
updatedAtColumn: berths.updatedAt,
|
updatedAtColumn: berths.updatedAt,
|
||||||
filters,
|
filters,
|
||||||
sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
|
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,
|
page: query.page,
|
||||||
pageSize: query.limit,
|
pageSize: query.limit,
|
||||||
searchColumns: [berths.mooringNumber, berths.area],
|
searchColumns: [berths.mooringNumber, berths.area],
|
||||||
|
|||||||
Reference in New Issue
Block a user