Compare commits
10 Commits
93e96da43b
...
366b0d79fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 366b0d79fd | |||
| 0ee3cd6073 | |||
| 91d8ee226b | |||
| 24e88ae32e | |||
| 7cf364e03a | |||
| 58203ca8ea | |||
| 8b7099c4c1 | |||
| 68da165b37 | |||
| 10b3b68851 | |||
| 3d9084c94b |
@@ -115,9 +115,14 @@ everything else is post-launch polish unless promoted.
|
|||||||
other) allowlisted against `SOURCES`. Both filters thread through the 5
|
other) allowlisted against `SOURCES`. Both filters thread through the 5
|
||||||
filtered Sales queries via a pure, unit-tested `parseSalesFilters`.
|
filtered Sales queries via a pure, unit-tested `parseSalesFilters`.
|
||||||
_Still open: replicate both on Operational + the other report pages._
|
_Still open: replicate both on Operational + the other report pages._
|
||||||
- ❌ **Empty-state copy per report** — currently shows a skeleton; spec
|
- ✅ **Empty-state copy per report** — **SHIPPED (2026-06-02).** A
|
||||||
wants a "this report needs data first" hint pointing at the right
|
window-independent `hasData` flag on the Sales / Operational /
|
||||||
onboarding step.
|
Financial routes drives a shared `<ReportEmptyState>` hero (named icon
|
||||||
|
- one-line body + onboarding action button) when the port has no
|
||||||
|
underlying data at all — distinct from the per-chart "no data in this
|
||||||
|
window" states, which already degraded gracefully. Targets: Sales →
|
||||||
|
Interests, Operational → Berths, Financial → Expenses. Spec:
|
||||||
|
`docs/superpowers/specs/2026-06-02-reports-polish-design.md`.
|
||||||
|
|
||||||
#### Phase 2 — Sales report gaps
|
#### Phase 2 — Sales report gaps
|
||||||
|
|
||||||
@@ -127,9 +132,17 @@ everything else is post-launch polish unless promoted.
|
|||||||
|
|
||||||
#### Phase 2 — Operational report gaps
|
#### Phase 2 — Operational report gaps
|
||||||
|
|
||||||
- ❌ **Operational-specific filters**: berth area · tenure type ·
|
- ⚠️ **Operational-specific filters**: **Area SHIPPED (2026-06-02)** —
|
||||||
document type · status filter. None of the four exist. The spec calls
|
a berth-area scope (`parseOperationalFilters` +
|
||||||
these out as drill-down affordances for the heatmap + tables.
|
`getOperationalAreaOptions`, threaded through the 5 berth-derived
|
||||||
|
service fns) re-queries the berth-count KPIs, occupancy-by-area,
|
||||||
|
utilisation heatmap, and vacant lists for the selected areas; trend +
|
||||||
|
tenancy/signing/docs panels stay port-wide with a "scoped to {areas}"
|
||||||
|
caption. Browser-verified (area A: total berths 117→11). **Status /
|
||||||
|
tenure type / document type deferred** — Status proved a light filter
|
||||||
|
here (can't retro-apply to historical trend charts; the vacant lists
|
||||||
|
are available-by-definition); see
|
||||||
|
`docs/superpowers/specs/2026-06-02-reports-polish-design.md`.
|
||||||
|
|
||||||
#### Phase 3 — Marketing report (LAUNCH-BLOCK if Marketing is in beta scope)
|
#### Phase 3 — Marketing report (LAUNCH-BLOCK if Marketing is in beta scope)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
getRecentPayments,
|
getRecentPayments,
|
||||||
getRefundLog,
|
getRefundLog,
|
||||||
getExpenseLedger,
|
getExpenseLedger,
|
||||||
|
financialHasData,
|
||||||
} from '@/lib/services/reports/financial.service';
|
} from '@/lib/services/reports/financial.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,6 +66,7 @@ export const GET = withAuth(
|
|||||||
recentPayments,
|
recentPayments,
|
||||||
refundLog,
|
refundLog,
|
||||||
expenseLedger,
|
expenseLedger,
|
||||||
|
hasData,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getFinancialKpis(ctx.portId, range),
|
getFinancialKpis(ctx.portId, range),
|
||||||
getRevenueByMonth(ctx.portId, range),
|
getRevenueByMonth(ctx.portId, range),
|
||||||
@@ -76,6 +78,7 @@ export const GET = withAuth(
|
|||||||
getRecentPayments(ctx.portId, range),
|
getRecentPayments(ctx.portId, range),
|
||||||
getRefundLog(ctx.portId, range),
|
getRefundLog(ctx.portId, range),
|
||||||
getExpenseLedger(ctx.portId, range),
|
getExpenseLedger(ctx.portId, range),
|
||||||
|
financialHasData(ctx.portId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -90,6 +93,7 @@ export const GET = withAuth(
|
|||||||
recentPayments,
|
recentPayments,
|
||||||
refundLog,
|
refundLog,
|
||||||
expenseLedger,
|
expenseLedger,
|
||||||
|
hasData,
|
||||||
range: { from: range.from.toISOString(), to: range.to.toISOString() },
|
range: { from: range.from.toISOString(), to: range.to.toISOString() },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { parseOperationalFilters } from '@/lib/services/reports/operational-filters';
|
||||||
import {
|
import {
|
||||||
getOperationalKpis,
|
getOperationalKpis,
|
||||||
getUtilisationHeatmap,
|
getUtilisationHeatmap,
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
getVacantBerths,
|
getVacantBerths,
|
||||||
getStuckSigning,
|
getStuckSigning,
|
||||||
getHighestValueVacant,
|
getHighestValueVacant,
|
||||||
|
getOperationalAreaOptions,
|
||||||
|
operationalHasData,
|
||||||
} from '@/lib/services/reports/operational.service';
|
} from '@/lib/services/reports/operational.service';
|
||||||
|
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
@@ -42,6 +45,7 @@ export const GET = withAuth(
|
|||||||
to: params.get('to') ?? undefined,
|
to: params.get('to') ?? undefined,
|
||||||
});
|
});
|
||||||
const range = resolveRange(from, to);
|
const range = resolveRange(from, to);
|
||||||
|
const filters = parseOperationalFilters(params);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
kpis,
|
kpis,
|
||||||
@@ -56,19 +60,23 @@ export const GET = withAuth(
|
|||||||
vacantBerths,
|
vacantBerths,
|
||||||
stuckSigning,
|
stuckSigning,
|
||||||
highestValueVacant,
|
highestValueVacant,
|
||||||
|
areaOptions,
|
||||||
|
hasData,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getOperationalKpis(ctx.portId, range),
|
getOperationalKpis(ctx.portId, range, filters),
|
||||||
getUtilisationHeatmap(ctx.portId),
|
getUtilisationHeatmap(ctx.portId, 24, filters),
|
||||||
getStatusMixOverTime(ctx.portId),
|
getStatusMixOverTime(ctx.portId),
|
||||||
getTenancyChurn(ctx.portId),
|
getTenancyChurn(ctx.portId),
|
||||||
getTenureDistribution(ctx.portId),
|
getTenureDistribution(ctx.portId),
|
||||||
getSigningBoxPlot(ctx.portId),
|
getSigningBoxPlot(ctx.portId),
|
||||||
getOccupancyByArea(ctx.portId),
|
getOccupancyByArea(ctx.portId, filters),
|
||||||
getDocumentsInPipeline(ctx.portId),
|
getDocumentsInPipeline(ctx.portId),
|
||||||
getTenanciesEndingSoon(ctx.portId),
|
getTenanciesEndingSoon(ctx.portId),
|
||||||
getVacantBerths(ctx.portId),
|
getVacantBerths(ctx.portId, 60, filters),
|
||||||
getStuckSigning(ctx.portId),
|
getStuckSigning(ctx.portId),
|
||||||
getHighestValueVacant(ctx.portId),
|
getHighestValueVacant(ctx.portId, 10, filters),
|
||||||
|
getOperationalAreaOptions(ctx.portId),
|
||||||
|
operationalHasData(ctx.portId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -85,6 +93,8 @@ export const GET = withAuth(
|
|||||||
vacantBerths,
|
vacantBerths,
|
||||||
stuckSigning,
|
stuckSigning,
|
||||||
highestValueVacant,
|
highestValueVacant,
|
||||||
|
areaOptions,
|
||||||
|
hasData,
|
||||||
range: {
|
range: {
|
||||||
from: range.from.toISOString(),
|
from: range.from.toISOString(),
|
||||||
to: range.to.toISOString(),
|
to: range.to.toISOString(),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
getClosingThisMonth,
|
getClosingThisMonth,
|
||||||
getRecentWins,
|
getRecentWins,
|
||||||
getLostReasonBreakdown,
|
getLostReasonBreakdown,
|
||||||
|
salesHasData,
|
||||||
} from '@/lib/services/reports/sales.service';
|
} from '@/lib/services/reports/sales.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,6 +88,7 @@ export const GET = withAuth(
|
|||||||
recentWins,
|
recentWins,
|
||||||
lostReasonBreakdown,
|
lostReasonBreakdown,
|
||||||
priorKpis,
|
priorKpis,
|
||||||
|
hasData,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getSalesKpis(ctx.portId, range),
|
getSalesKpis(ctx.portId, range),
|
||||||
getPipelineFunnel(ctx.portId),
|
getPipelineFunnel(ctx.portId),
|
||||||
@@ -105,6 +107,7 @@ export const GET = withAuth(
|
|||||||
// with the main batch (depends only on the derived priorBounds);
|
// with the main batch (depends only on the derived priorBounds);
|
||||||
// resolves to null when the toggle is off so we pay nothing.
|
// resolves to null when the toggle is off so we pay nothing.
|
||||||
priorBounds ? getSalesKpis(ctx.portId, priorBounds) : Promise.resolve(null),
|
priorBounds ? getSalesKpis(ctx.portId, priorBounds) : Promise.resolve(null),
|
||||||
|
salesHasData(ctx.portId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const comparison =
|
const comparison =
|
||||||
@@ -134,6 +137,7 @@ export const GET = withAuth(
|
|||||||
closingThisMonth,
|
closingThisMonth,
|
||||||
recentWins,
|
recentWins,
|
||||||
lostReasonBreakdown,
|
lostReasonBreakdown,
|
||||||
|
hasData,
|
||||||
range: {
|
range: {
|
||||||
from: range.from.toISOString(),
|
from: range.from.toISOString(),
|
||||||
to: range.to.toISOString(),
|
to: range.to.toISOString(),
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
|
|||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { formatMoney, formatMoneyCompact, formatNumber } from '@/lib/reports/format-currency';
|
import { formatMoney, formatMoneyCompact, formatNumber } from '@/lib/reports/format-currency';
|
||||||
import type { ReportPayload } from '@/lib/reports/types';
|
import type { ReportPayload } from '@/lib/reports/types';
|
||||||
|
import { ReportEmptyState } from '@/components/reports/shared/report-empty-state';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { Wallet } from 'lucide-react';
|
||||||
|
|
||||||
// ─── Payload types (mirror the /api/v1/reports/financial response) ───────────
|
// ─── Payload types (mirror the /api/v1/reports/financial response) ───────────
|
||||||
|
|
||||||
@@ -119,6 +122,7 @@ interface FinancialPayload {
|
|||||||
refundLog: RefundRow[];
|
refundLog: RefundRow[];
|
||||||
expenseLedger: ExpenseLedgerRow[];
|
expenseLedger: ExpenseLedgerRow[];
|
||||||
range: { from: string; to: string };
|
range: { from: string; to: string };
|
||||||
|
hasData: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +142,7 @@ const DONUT_COLORS = [
|
|||||||
'hsl(var(--chart-6))',
|
'hsl(var(--chart-6))',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function FinancialReportClient({ portSlug: _portSlug }: { portSlug: string }) {
|
export function FinancialReportClient({ portSlug }: { portSlug: string }) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const initialTemplateId = searchParams?.get('templateId') ?? null;
|
const initialTemplateId = searchParams?.get('templateId') ?? null;
|
||||||
|
|
||||||
@@ -271,6 +275,25 @@ export function FinancialReportClient({ portSlug: _portSlug }: { portSlug: strin
|
|||||||
|
|
||||||
const isLoading = query.isLoading || !kpis;
|
const isLoading = query.isLoading || !kpis;
|
||||||
|
|
||||||
|
if (!query.isLoading && d && !d.hasData) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Reports"
|
||||||
|
title="Financial"
|
||||||
|
description="Revenue collected, deposits, outstanding balances, cash flow, and expense breakdown."
|
||||||
|
/>
|
||||||
|
<ReportEmptyState
|
||||||
|
icon={Wallet}
|
||||||
|
title="No financial activity yet"
|
||||||
|
body="Record a payment on a deal or log an expense to see revenue, deposits, and cash flow."
|
||||||
|
actionLabel="Go to expenses"
|
||||||
|
actionHref={`/${portSlug}/expenses` as Route}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
|
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
|
||||||
|
import {
|
||||||
|
FilterBar,
|
||||||
|
type FilterDefinition,
|
||||||
|
type FilterValues,
|
||||||
|
} from '@/components/shared/filter-bar';
|
||||||
import { ReportExportButton } from '@/components/reports/shared/report-export-button';
|
import { ReportExportButton } from '@/components/reports/shared/report-export-button';
|
||||||
import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button';
|
import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button';
|
||||||
import { formatMoneyCompact as formatMoney } from '@/lib/reports/format-currency';
|
import { formatMoneyCompact as formatMoney } from '@/lib/reports/format-currency';
|
||||||
@@ -33,6 +38,7 @@ import { apiFetch } from '@/lib/api/client';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useUIStore } from '@/stores/ui-store';
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
import type { ReportPayload } from '@/lib/reports/types';
|
import type { ReportPayload } from '@/lib/reports/types';
|
||||||
|
import { ReportEmptyState } from '@/components/reports/shared/report-empty-state';
|
||||||
|
|
||||||
import { OperationalHeatmap } from './operational-heatmap';
|
import { OperationalHeatmap } from './operational-heatmap';
|
||||||
import { OperationalSigningBoxPlot } from './operational-signing-box-plot';
|
import { OperationalSigningBoxPlot } from './operational-signing-box-plot';
|
||||||
@@ -162,6 +168,8 @@ interface OperationalReportPayload {
|
|||||||
stuckSigning: StuckSigningRow[];
|
stuckSigning: StuckSigningRow[];
|
||||||
highestValueVacant: HighestValueVacantRow[];
|
highestValueVacant: HighestValueVacantRow[];
|
||||||
range: { from: string; to: string };
|
range: { from: string; to: string };
|
||||||
|
hasData: boolean;
|
||||||
|
areaOptions: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +177,7 @@ interface OperationalTemplateConfig extends Record<string, unknown> {
|
|||||||
kind: 'operational';
|
kind: 'operational';
|
||||||
range: DateRange;
|
range: DateRange;
|
||||||
statusMixMode: 'absolute' | 'proportional';
|
statusMixMode: 'absolute' | 'proportional';
|
||||||
|
filters?: FilterValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OperationalReportClient({ portSlug }: { portSlug: string }) {
|
export function OperationalReportClient({ portSlug }: { portSlug: string }) {
|
||||||
@@ -178,6 +187,7 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) {
|
|||||||
const [range, setRange] = useState<DateRange>('30d');
|
const [range, setRange] = useState<DateRange>('30d');
|
||||||
const [statusMixMode, setStatusMixMode] = useState<'absolute' | 'proportional'>('proportional');
|
const [statusMixMode, setStatusMixMode] = useState<'absolute' | 'proportional'>('proportional');
|
||||||
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId);
|
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId);
|
||||||
|
const [filterValues, setFilterValues] = useState<FilterValues>({});
|
||||||
|
|
||||||
// User-driven setters clear the active-template badge; template
|
// User-driven setters clear the active-template badge; template
|
||||||
// apply uses the raw setters so it doesn't immediately clear its
|
// apply uses the raw setters so it doesn't immediately clear its
|
||||||
@@ -192,23 +202,47 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) {
|
|||||||
setActiveTemplateId(null);
|
setActiveTemplateId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((key: string, value: unknown) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setActiveTemplateId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFiltersClear = useCallback(() => {
|
||||||
|
setFilterValues({});
|
||||||
|
setActiveTemplateId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const currentConfig: OperationalTemplateConfig = useMemo(
|
const currentConfig: OperationalTemplateConfig = useMemo(
|
||||||
() => ({ kind: 'operational', range, statusMixMode }),
|
() => ({ kind: 'operational', range, statusMixMode, filters: filterValues }),
|
||||||
[range, statusMixMode],
|
[range, statusMixMode, filterValues],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleApplyTemplate = useCallback((config: OperationalTemplateConfig) => {
|
const handleApplyTemplate = useCallback((config: OperationalTemplateConfig) => {
|
||||||
if (config.range) setRange(config.range);
|
if (config.range) setRange(config.range);
|
||||||
if (config.statusMixMode) setStatusMixMode(config.statusMixMode);
|
if (config.statusMixMode) setStatusMixMode(config.statusMixMode);
|
||||||
|
setFilterValues(config.filters ?? {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const bounds = useMemo(() => rangeToBounds(range), [range]);
|
const bounds = useMemo(() => rangeToBounds(range), [range]);
|
||||||
|
|
||||||
|
const filterQs = useMemo(() => {
|
||||||
|
const areas = filterValues.area;
|
||||||
|
return Array.isArray(areas) && areas.length > 0
|
||||||
|
? `&area=${encodeURIComponent(areas.join(','))}`
|
||||||
|
: '';
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
const query = useQuery<OperationalReportPayload>({
|
const query = useQuery<OperationalReportPayload>({
|
||||||
queryKey: ['reports', 'operational', bounds.from.toISOString(), bounds.to.toISOString()],
|
queryKey: [
|
||||||
|
'reports',
|
||||||
|
'operational',
|
||||||
|
bounds.from.toISOString(),
|
||||||
|
bounds.to.toISOString(),
|
||||||
|
filterQs,
|
||||||
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<OperationalReportPayload>(
|
apiFetch<OperationalReportPayload>(
|
||||||
`/api/v1/reports/operational?from=${encodeURIComponent(bounds.from.toISOString())}&to=${encodeURIComponent(bounds.to.toISOString())}`,
|
`/api/v1/reports/operational?from=${encodeURIComponent(bounds.from.toISOString())}&to=${encodeURIComponent(bounds.to.toISOString())}${filterQs}`,
|
||||||
),
|
),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
@@ -216,6 +250,19 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) {
|
|||||||
const data = query.data?.data;
|
const data = query.data?.data;
|
||||||
const tenanciesOn = data?.kpis.tenanciesModuleEnabled ?? false;
|
const tenanciesOn = data?.kpis.tenanciesModuleEnabled ?? false;
|
||||||
|
|
||||||
|
const areaOptions = query.data?.data.areaOptions;
|
||||||
|
const filterDefs = useMemo<FilterDefinition[]>(() => {
|
||||||
|
if (!areaOptions || areaOptions.length === 0) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'area',
|
||||||
|
label: 'Berth area',
|
||||||
|
type: 'multi-select',
|
||||||
|
options: areaOptions.map((a) => ({ value: a, label: a })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [areaOptions]);
|
||||||
|
|
||||||
function buildExportPayload(): ReportPayload {
|
function buildExportPayload(): ReportPayload {
|
||||||
if (!data) throw new Error('Report still loading');
|
if (!data) throw new Error('Report still loading');
|
||||||
return {
|
return {
|
||||||
@@ -312,6 +359,25 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!query.isLoading && data && !data.hasData) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Reports"
|
||||||
|
title="Operational"
|
||||||
|
description="Berth utilisation, tenancy lifecycle, signing turnaround, operational bottlenecks."
|
||||||
|
/>
|
||||||
|
<ReportEmptyState
|
||||||
|
icon={Anchor}
|
||||||
|
title="No berths yet"
|
||||||
|
body="Add berths to see utilisation, occupancy, and signing turnaround."
|
||||||
|
actionLabel="Add berths"
|
||||||
|
actionHref={`/${portSlug}/berths` as Route}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -320,6 +386,14 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) {
|
|||||||
description="Berth utilisation, tenancy lifecycle, signing turnaround, operational bottlenecks."
|
description="Berth utilisation, tenancy lifecycle, signing turnaround, operational bottlenecks."
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{filterDefs.length > 0 ? (
|
||||||
|
<FilterBar
|
||||||
|
filters={filterDefs}
|
||||||
|
values={filterValues}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
onClear={handleFiltersClear}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<DateRangePicker value={range} onChange={handleRangeChange} />
|
<DateRangePicker value={range} onChange={handleRangeChange} />
|
||||||
<ReportTemplatesButton<OperationalTemplateConfig>
|
<ReportTemplatesButton<OperationalTemplateConfig>
|
||||||
kind="operational"
|
kind="operational"
|
||||||
@@ -334,6 +408,16 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{Array.isArray(filterValues.area) && filterValues.area.length > 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Berth surfaces (KPIs, occupancy, vacant lists) scoped to:{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{(filterValues.area as string[]).join(', ')}
|
||||||
|
</span>
|
||||||
|
. Trend and tenancy panels show the full port.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* KPI strip */}
|
{/* KPI strip */}
|
||||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
{query.isLoading || !data ? (
|
{query.isLoading || !data ? (
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import {
|
|||||||
} from '@/lib/constants';
|
} from '@/lib/constants';
|
||||||
import { formatMoney } from '@/lib/reports/format-currency';
|
import { formatMoney } from '@/lib/reports/format-currency';
|
||||||
import type { ReportPayload } from '@/lib/reports/types';
|
import type { ReportPayload } from '@/lib/reports/types';
|
||||||
|
import { ReportEmptyState } from '@/components/reports/shared/report-empty-state';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
|
||||||
import { SalesPipelineFunnel } from './sales-pipeline-funnel';
|
import { SalesPipelineFunnel } from './sales-pipeline-funnel';
|
||||||
import { SalesStageVelocity } from './sales-stage-velocity';
|
import { SalesStageVelocity } from './sales-stage-velocity';
|
||||||
@@ -211,6 +213,7 @@ interface SalesReportPayload {
|
|||||||
recentWins: RecentWinRow[];
|
recentWins: RecentWinRow[];
|
||||||
lostReasonBreakdown: LostReasonRow[];
|
lostReasonBreakdown: LostReasonRow[];
|
||||||
range: { from: string; to: string };
|
range: { from: string; to: string };
|
||||||
|
hasData: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +277,7 @@ interface SalesTemplateConfig extends Record<string, unknown> {
|
|||||||
compare?: boolean;
|
compare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string }) {
|
export function SalesReportClient({ portSlug }: { portSlug: string }) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const initialTemplateId = searchParams?.get('templateId') ?? null;
|
const initialTemplateId = searchParams?.get('templateId') ?? null;
|
||||||
|
|
||||||
@@ -345,6 +348,7 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
|||||||
});
|
});
|
||||||
|
|
||||||
const kpis = query.data?.data.kpis;
|
const kpis = query.data?.data.kpis;
|
||||||
|
const data = query.data?.data;
|
||||||
const deltas = query.data?.data.comparison?.deltas ?? null;
|
const deltas = query.data?.data.comparison?.deltas ?? null;
|
||||||
const funnel = query.data?.data.funnel ?? [];
|
const funnel = query.data?.data.funnel ?? [];
|
||||||
const stageVelocity = query.data?.data.stageVelocity ?? [];
|
const stageVelocity = query.data?.data.stageVelocity ?? [];
|
||||||
@@ -594,6 +598,25 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!query.isLoading && data && !data.hasData) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Reports"
|
||||||
|
title="Sales performance"
|
||||||
|
description="Rep performance, win rates, pipeline value, stalled deals, and deal heat."
|
||||||
|
/>
|
||||||
|
<ReportEmptyState
|
||||||
|
icon={TrendingUp}
|
||||||
|
title="No sales activity yet"
|
||||||
|
body="Once you add clients and log interests, this report fills with win rates, pipeline value, and deal heat."
|
||||||
|
actionLabel="Add an interest"
|
||||||
|
actionHref={`/${portSlug}/interests` as Route}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
|||||||
39
src/components/reports/shared/report-empty-state.tsx
Normal file
39
src/components/reports/shared/report-empty-state.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ReportEmptyStateProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
actionLabel: string;
|
||||||
|
actionHref: Route;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report-level empty state. Rendered when a report's `hasData` flag is
|
||||||
|
* false (the port has no underlying data at all), in place of the report
|
||||||
|
* body — distinct from the per-chart "no data in this window" states.
|
||||||
|
*/
|
||||||
|
export function ReportEmptyState({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
actionLabel,
|
||||||
|
actionHref,
|
||||||
|
}: ReportEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border px-6 py-20 text-center">
|
||||||
|
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Icon className="h-6 w-6 text-muted-foreground" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||||
|
<p className="mt-1.5 max-w-sm text-sm text-muted-foreground">{body}</p>
|
||||||
|
<Button asChild className="mt-5">
|
||||||
|
<Link href={actionHref}>{actionLabel}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -630,3 +630,23 @@ export async function getExpenseLedger(
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Window-independent existence check: does this port have any payment OR
|
||||||
|
* expense? Drives the Financial report-level empty state.
|
||||||
|
*/
|
||||||
|
export async function financialHasData(portId: string): Promise<boolean> {
|
||||||
|
const [pay, exp] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ one: sql<number>`1` })
|
||||||
|
.from(payments)
|
||||||
|
.where(eq(payments.portId, portId))
|
||||||
|
.limit(1),
|
||||||
|
db
|
||||||
|
.select({ one: sql<number>`1` })
|
||||||
|
.from(expenses)
|
||||||
|
.where(eq(expenses.portId, portId))
|
||||||
|
.limit(1),
|
||||||
|
]);
|
||||||
|
return pay.length > 0 || exp.length > 0;
|
||||||
|
}
|
||||||
|
|||||||
27
src/lib/services/reports/operational-filters.ts
Normal file
27
src/lib/services/reports/operational-filters.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Operational report filters. Mirrors `sales-filters.ts`: the parser is a
|
||||||
|
* pure, unit-testable function so the route just hands it the query params.
|
||||||
|
*
|
||||||
|
* Beta scope is Area only (a berth-area scope). The shape is intentionally
|
||||||
|
* an object so a Status dimension can be added later without a rename.
|
||||||
|
*/
|
||||||
|
export interface OperationalFilters {
|
||||||
|
areas?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the `area` CSV query param into a free list of port-defined area
|
||||||
|
* strings. Empty / whitespace entries are dropped. Drizzle parameterises
|
||||||
|
* the downstream `inArray`, so unvalidated values are injection-safe.
|
||||||
|
* Returns `undefined` when no areas are active (→ no filter).
|
||||||
|
*/
|
||||||
|
export function parseOperationalFilters(params: URLSearchParams): OperationalFilters | undefined {
|
||||||
|
const raw = params.get('area');
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const areas = raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
if (areas.length === 0) return undefined;
|
||||||
|
return { areas };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, desc, eq, gte, isNotNull, isNull, lte, sql } from 'drizzle-orm';
|
import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { berths } from '@/lib/db/schema/berths';
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
@@ -6,6 +6,7 @@ import { berthTenancies } from '@/lib/db/schema/tenancies';
|
|||||||
import { clients } from '@/lib/db/schema/clients';
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
import { documents } from '@/lib/db/schema/documents';
|
import { documents } from '@/lib/db/schema/documents';
|
||||||
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
|
import type { OperationalFilters } from './operational-filters';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service layer for the Operational report.
|
* Service layer for the Operational report.
|
||||||
@@ -25,6 +26,17 @@ interface DateRange {
|
|||||||
to: Date;
|
to: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional berth-area WHERE-condition. Returns `undefined` when no area
|
||||||
|
* filter is active, so it drops cleanly out of a drizzle `and(...)`
|
||||||
|
* (which ignores undefined operands).
|
||||||
|
*/
|
||||||
|
function areaCond(filters?: OperationalFilters) {
|
||||||
|
return filters?.areas && filters.areas.length > 0
|
||||||
|
? inArray(berths.area, filters.areas)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OperationalKpis {
|
export interface OperationalKpis {
|
||||||
totalBerths: number;
|
totalBerths: number;
|
||||||
soldPct: number;
|
soldPct: number;
|
||||||
@@ -54,6 +66,7 @@ export interface OperationalKpis {
|
|||||||
export async function getOperationalKpis(
|
export async function getOperationalKpis(
|
||||||
portId: string,
|
portId: string,
|
||||||
range: DateRange,
|
range: DateRange,
|
||||||
|
filters?: OperationalFilters,
|
||||||
): Promise<OperationalKpis> {
|
): Promise<OperationalKpis> {
|
||||||
const [
|
const [
|
||||||
totalBerths,
|
totalBerths,
|
||||||
@@ -67,11 +80,11 @@ export async function getOperationalKpis(
|
|||||||
signingTurnaround,
|
signingTurnaround,
|
||||||
conflicts,
|
conflicts,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
countActiveBerths(portId),
|
countActiveBerths(portId, filters),
|
||||||
countBerthsByStatusNow(portId, 'sold'),
|
countBerthsByStatusNow(portId, 'sold', filters),
|
||||||
countBerthsByStatusAtTimestamp(portId, 'sold', range.from),
|
countBerthsByStatusAtTimestamp(portId, 'sold', range.from, filters),
|
||||||
countBerthsByStatusNow(portId, 'under_offer'),
|
countBerthsByStatusNow(portId, 'under_offer', filters),
|
||||||
countBerthsByStatusAtTimestamp(portId, 'under_offer', range.from),
|
countBerthsByStatusAtTimestamp(portId, 'under_offer', range.from, filters),
|
||||||
isTenanciesModuleEnabled(portId),
|
isTenanciesModuleEnabled(portId),
|
||||||
countActiveTenancies(portId),
|
countActiveTenancies(portId),
|
||||||
medianTenancyLengthYears(portId),
|
medianTenancyLengthYears(portId),
|
||||||
@@ -116,6 +129,7 @@ export interface UtilisationCell {
|
|||||||
export async function getUtilisationHeatmap(
|
export async function getUtilisationHeatmap(
|
||||||
portId: string,
|
portId: string,
|
||||||
months = 24,
|
months = 24,
|
||||||
|
filters?: OperationalFilters,
|
||||||
): Promise<UtilisationCell[]> {
|
): Promise<UtilisationCell[]> {
|
||||||
// For each month buckets we walk all berths and compute the
|
// For each month buckets we walk all berths and compute the
|
||||||
// share that was 'sold' or 'under_offer' at month-end. To keep
|
// share that was 'sold' or 'under_offer' at month-end. To keep
|
||||||
@@ -130,7 +144,7 @@ export async function getUtilisationHeatmap(
|
|||||||
const berthRows = await db
|
const berthRows = await db
|
||||||
.select({ id: berths.id, area: berths.area, status: berths.status })
|
.select({ id: berths.id, area: berths.area, status: berths.status })
|
||||||
.from(berths)
|
.from(berths)
|
||||||
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)));
|
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters)));
|
||||||
if (berthRows.length === 0) return [];
|
if (berthRows.length === 0) return [];
|
||||||
|
|
||||||
const areaSet = new Set<string>();
|
const areaSet = new Set<string>();
|
||||||
@@ -511,7 +525,10 @@ export interface AreaOccupancyRow {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOccupancyByArea(portId: string): Promise<AreaOccupancyRow[]> {
|
export async function getOccupancyByArea(
|
||||||
|
portId: string,
|
||||||
|
filters?: OperationalFilters,
|
||||||
|
): Promise<AreaOccupancyRow[]> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
area: berths.area,
|
area: berths.area,
|
||||||
@@ -519,7 +536,7 @@ export async function getOccupancyByArea(portId: string): Promise<AreaOccupancyR
|
|||||||
n: sql<number>`count(*)::int`,
|
n: sql<number>`count(*)::int`,
|
||||||
})
|
})
|
||||||
.from(berths)
|
.from(berths)
|
||||||
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)))
|
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters)))
|
||||||
.groupBy(berths.area, berths.status);
|
.groupBy(berths.area, berths.status);
|
||||||
|
|
||||||
const byArea = new Map<string, AreaOccupancyRow>();
|
const byArea = new Map<string, AreaOccupancyRow>();
|
||||||
@@ -652,6 +669,7 @@ export interface VacantBerthRow {
|
|||||||
export async function getVacantBerths(
|
export async function getVacantBerths(
|
||||||
portId: string,
|
portId: string,
|
||||||
minDaysAvailable = 60,
|
minDaysAvailable = 60,
|
||||||
|
filters?: OperationalFilters,
|
||||||
): Promise<VacantBerthRow[]> {
|
): Promise<VacantBerthRow[]> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -667,7 +685,12 @@ export async function getVacantBerths(
|
|||||||
})
|
})
|
||||||
.from(berths)
|
.from(berths)
|
||||||
.where(
|
.where(
|
||||||
and(eq(berths.portId, portId), eq(berths.status, 'available'), isNull(berths.archivedAt)),
|
and(
|
||||||
|
eq(berths.portId, portId),
|
||||||
|
eq(berths.status, 'available'),
|
||||||
|
isNull(berths.archivedAt),
|
||||||
|
areaCond(filters),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.orderBy(berths.mooringNumber);
|
.orderBy(berths.mooringNumber);
|
||||||
|
|
||||||
@@ -775,6 +798,7 @@ export interface HighestValueVacantRow {
|
|||||||
export async function getHighestValueVacant(
|
export async function getHighestValueVacant(
|
||||||
portId: string,
|
portId: string,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
|
filters?: OperationalFilters,
|
||||||
): Promise<HighestValueVacantRow[]> {
|
): Promise<HighestValueVacantRow[]> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -795,6 +819,7 @@ export async function getHighestValueVacant(
|
|||||||
eq(berths.status, 'available'),
|
eq(berths.status, 'available'),
|
||||||
isNull(berths.archivedAt),
|
isNull(berths.archivedAt),
|
||||||
isNotNull(berths.price),
|
isNotNull(berths.price),
|
||||||
|
areaCond(filters),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(berths.price))
|
.orderBy(desc(berths.price))
|
||||||
@@ -820,21 +845,61 @@ export async function getHighestValueVacant(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinct, non-null berth areas for the Operational report's Area filter.
|
||||||
|
* Mirrors `getRepFilterOptions` in sales.service.ts. The FilterBar hides
|
||||||
|
* the Area control when this is empty, so ports with no areas defined never
|
||||||
|
* see it.
|
||||||
|
*/
|
||||||
|
export async function getOperationalAreaOptions(portId: string): Promise<string[]> {
|
||||||
|
const rows = await db
|
||||||
|
.selectDistinct({ area: berths.area })
|
||||||
|
.from(berths)
|
||||||
|
.where(and(eq(berths.portId, portId), isNotNull(berths.area), isNull(berths.archivedAt)))
|
||||||
|
.orderBy(berths.area);
|
||||||
|
return rows.map((r) => r.area).filter((a): a is string => a !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Window-independent existence check: does this port have any berth at all?
|
||||||
|
* Drives the report-level empty state (distinct from the per-window empty
|
||||||
|
* states the charts already render).
|
||||||
|
*/
|
||||||
|
export async function operationalHasData(portId: string): Promise<boolean> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ one: sql<number>`1` })
|
||||||
|
.from(berths)
|
||||||
|
.where(eq(berths.portId, portId))
|
||||||
|
.limit(1);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Internals ──────────────────────────────────────────────────────────────
|
// ─── Internals ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function countActiveBerths(portId: string): Promise<number> {
|
async function countActiveBerths(portId: string, filters?: OperationalFilters): Promise<number> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ value: sql<number>`count(*)::int` })
|
.select({ value: sql<number>`count(*)::int` })
|
||||||
.from(berths)
|
.from(berths)
|
||||||
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)));
|
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters)));
|
||||||
return row?.value ?? 0;
|
return row?.value ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function countBerthsByStatusNow(portId: string, status: string): Promise<number> {
|
async function countBerthsByStatusNow(
|
||||||
|
portId: string,
|
||||||
|
status: string,
|
||||||
|
filters?: OperationalFilters,
|
||||||
|
): Promise<number> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ value: sql<number>`count(*)::int` })
|
.select({ value: sql<number>`count(*)::int` })
|
||||||
.from(berths)
|
.from(berths)
|
||||||
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt), eq(berths.status, status)));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(berths.portId, portId),
|
||||||
|
isNull(berths.archivedAt),
|
||||||
|
eq(berths.status, status),
|
||||||
|
areaCond(filters),
|
||||||
|
),
|
||||||
|
);
|
||||||
return row?.value ?? 0;
|
return row?.value ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -848,11 +913,12 @@ async function countBerthsByStatusAtTimestamp(
|
|||||||
portId: string,
|
portId: string,
|
||||||
targetStatus: string,
|
targetStatus: string,
|
||||||
at: Date,
|
at: Date,
|
||||||
|
filters?: OperationalFilters,
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const berthRows = await db
|
const berthRows = await db
|
||||||
.select({ id: berths.id, status: berths.status, createdAt: berths.createdAt })
|
.select({ id: berths.id, status: berths.status, createdAt: berths.createdAt })
|
||||||
.from(berths)
|
.from(berths)
|
||||||
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)));
|
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters)));
|
||||||
|
|
||||||
const auditRows = await db.execute<{
|
const auditRows = await db.execute<{
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
|
|||||||
@@ -1663,6 +1663,19 @@ async function fetchNewLeads(
|
|||||||
return { total, bySource };
|
return { total, bySource };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Window-independent existence check: does this port have any interest at
|
||||||
|
* all? Drives the Sales report-level empty state.
|
||||||
|
*/
|
||||||
|
export async function salesHasData(portId: string): Promise<boolean> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ one: sql<number>`1` })
|
||||||
|
.from(interests)
|
||||||
|
.where(eq(interests.portId, portId))
|
||||||
|
.limit(1);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export so the active-interest helper is available to callers that
|
// Re-export so the active-interest helper is available to callers that
|
||||||
// want to add their own constraints layered onto the same predicate.
|
// want to add their own constraints layered onto the same predicate.
|
||||||
export { activeInterestsWhere };
|
export { activeInterestsWhere };
|
||||||
|
|||||||
29
tests/unit/services/reports/operational-filters.test.ts
Normal file
29
tests/unit/services/reports/operational-filters.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { parseOperationalFilters } from '@/lib/services/reports/operational-filters';
|
||||||
|
|
||||||
|
function params(qs: string): URLSearchParams {
|
||||||
|
return new URLSearchParams(qs);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseOperationalFilters', () => {
|
||||||
|
it('returns undefined when no area param is present', () => {
|
||||||
|
expect(parseOperationalFilters(params(''))).toBeUndefined();
|
||||||
|
expect(parseOperationalFilters(params('from=x&to=y'))).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a single area', () => {
|
||||||
|
expect(parseOperationalFilters(params('area=A'))).toEqual({ areas: ['A'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a CSV of areas and trims whitespace', () => {
|
||||||
|
expect(parseOperationalFilters(params('area=A,%20B%20,C'))).toEqual({
|
||||||
|
areas: ['A', 'B', 'C'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops empty / whitespace-only entries, returning undefined when nothing is left', () => {
|
||||||
|
expect(parseOperationalFilters(params('area=%20,%20'))).toBeUndefined();
|
||||||
|
expect(parseOperationalFilters(params('area='))).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user