diff --git a/apps/portal/components/partner/CustomerTaskPanel.vue b/apps/portal/components/partner/CustomerTaskPanel.vue index 3f287f2..9fa0bcb 100644 --- a/apps/portal/components/partner/CustomerTaskPanel.vue +++ b/apps/portal/components/partner/CustomerTaskPanel.vue @@ -3,7 +3,7 @@ // Pre-fills notes from the health drivers and lets the partner tweak before // creating the task. -import type { CustomerOrg } from '~/data/customers' +import type { CustomerOrg } from '~/types/partner' export interface TaskContext { customer: CustomerOrg diff --git a/apps/portal/components/partner/EnterCustomerConfirmModal.vue b/apps/portal/components/partner/EnterCustomerConfirmModal.vue index 22d42d6..320b328 100644 --- a/apps/portal/components/partner/EnterCustomerConfirmModal.vue +++ b/apps/portal/components/partner/EnterCustomerConfirmModal.vue @@ -4,7 +4,7 @@ // customer org will be logged under their partner identity, and prompts for // an optional (but recommended) reason — captured into the customer audit log. -import type { CustomerOrg } from '~/data/customers' +import type { CustomerOrg } from '~/types/partner' const props = defineProps<{ customer: CustomerOrg | null }>() const emit = defineEmits<{ close: []; confirm: [reason: string] }>() diff --git a/apps/portal/composables/usePartnerMode.ts b/apps/portal/composables/usePartnerMode.ts index b7c03fc..3cfac08 100644 --- a/apps/portal/composables/usePartnerMode.ts +++ b/apps/portal/composables/usePartnerMode.ts @@ -6,7 +6,7 @@ // identity (not the customer's) — the design spec is explicit about this for // trust. For the prototype we just hold the customer id. -import type { CustomerOrg } from '~/data/customers' +import type { CustomerOrg } from '~/types/partner' const activeCustomerId = ref(null) diff --git a/apps/portal/composables/usePartnerMrr.ts b/apps/portal/composables/usePartnerMrr.ts new file mode 100644 index 0000000..dfb4d84 --- /dev/null +++ b/apps/portal/composables/usePartnerMrr.ts @@ -0,0 +1,51 @@ +// Shared fetch + derivations for partner MRR from /api/partner/mrr +// (platform-api /me/partner/mrr). Like usePartnerTenants, the dashboard, +// customers, and billing pages all read this under the 'partner-mrr' key, so +// one cached payload serves all of them. This composable also owns the +// derivations that were duplicated per page: the per-tenant lookup Map and the +// per-currency display totals (amounts are never FX-summed across currencies). +// +// const { mrr, mrrByTenant, totalsDisplay, refresh } = usePartnerMrr() +// +// Synchronous (non-async) so `useFetch` runs in the caller's setup context even +// when called after another await. Call WITHOUT await. + +import type { MrrBreakdownRow, MrrResponse } from '~/types/partner' + +export function usePartnerMrr() { + const { data: mrr, refresh, error, pending } = useFetch('/api/partner/mrr', { + key: 'partner-mrr', + default: () => ({ totals: [], breakdown: [] }), + }) + + // tenantId → its subscription's MRR row, for O(1) per-row lookups in the + // customers table without a second fetch. + const mrrByTenant = computed(() => { + const m = new Map() + for (const row of mrr.value?.breakdown ?? []) m.set(row.tenantId, row) + return m + }) + + // Per-currency totals in major units (minor / 100), e.g. { DKK: 55_750 }. + const totalsDisplay = computed(() => + (mrr.value?.totals ?? []).map((t) => ({ + currency: t.currency, + majorAmount: Math.round(t.monthlyMinor / 100), + })), + ) + + // Compact one-line summary, e.g. "55.750 DKK + 1.200 EUR / mo". + const totalsLine = computed(() => { + const parts = totalsDisplay.value.map( + (t) => `${t.majorAmount.toLocaleString('da-DK')} ${t.currency}`, + ) + if (parts.length === 0) return '0 DKK / mo' + return parts.join(' + ') + ' / mo' + }) + + // True when any subscription is custom/Enterprise-priced (renders as + // "custom" rather than a misleading 0 in the totals). + const hasCustomPriced = computed(() => (mrr.value?.breakdown ?? []).some((b) => b.custom)) + + return { mrr, mrrByTenant, totalsDisplay, totalsLine, hasCustomPriced, refresh, error, pending } +} diff --git a/apps/portal/composables/usePartnerTenants.ts b/apps/portal/composables/usePartnerTenants.ts new file mode 100644 index 0000000..76dcac8 --- /dev/null +++ b/apps/portal/composables/usePartnerTenants.ts @@ -0,0 +1,21 @@ +// Shared fetch of the signed-in partner's customer tenants from +// /api/partner/tenants (platform-api /me/partner/tenants). Several pages need +// this list — dashboard, customers, and (later) billing — and they all key the +// fetch as 'partner-tenants', so Nuxt dedupes to a single round-trip and shared +// payload. Centralizing it here removes the divergent inline PartnerTenantDoc +// copies that drifted between pages. +// +// Synchronous (non-async) so `useFetch` is invoked directly in the caller's +// setup context — safe to call after another `await` in setup, unlike an async +// wrapper. Call WITHOUT await: +// const { tenants, refresh } = usePartnerTenants() + +import type { PartnerTenantDoc } from '~/types/partner' + +export function usePartnerTenants() { + const { data: tenants, refresh, error, pending } = useFetch( + '/api/partner/tenants', + { key: 'partner-tenants', default: () => [] }, + ) + return { tenants, refresh, error, pending } +} diff --git a/apps/portal/data/customers.ts b/apps/portal/data/customers.ts index 8c898dd..02696c6 100644 --- a/apps/portal/data/customers.ts +++ b/apps/portal/data/customers.ts @@ -2,23 +2,9 @@ // orgs. Numbers seeded to match partner-screens.jsx (the canonical design // source) line for line: same customer set, same MRR, seats, status, mark. -export type CustomerStatus = 'healthy' | 'attention' | 'past_due' | 'trial' | 'suspended' - -export interface CustomerOrg { - id: string - name: string - domain: string - plan: 'starter' | 'business' | 'enterprise' - planLabel: 'Starter' | 'Business' | 'Enterprise' - seats: { used: number; total: number } - health: number - status: CustomerStatus - mrrDkk: number - brandColor: string - industry: string - createdOn: string - since: string -} +// Types moved to ~/types/partner so the fixture *data* below can be deleted +// page-by-page (as each goes real) without breaking type-only importers. +import type { CustomerOrg } from '~/types/partner' export const partner = { id: 'p-nordicmsp', diff --git a/apps/portal/types/partner.ts b/apps/portal/types/partner.ts new file mode 100644 index 0000000..ad8518c --- /dev/null +++ b/apps/portal/types/partner.ts @@ -0,0 +1,85 @@ +// Shared partner-domain types. These were previously coupled to the fixture +// module (data/customers.ts), which blocked deleting the mock data without +// breaking every type-only import. Living here, the types outlive the +// fixtures: pages and composables import from `~/types/partner`, and the +// fixture data exports can be removed page-by-page as each one goes real. + +// Currencies the platform prices in. Subscriptions are always reported +// per-currency (never FX-summed), so this propagates through MRR + billing. +export type Currency = 'DKK' | 'EUR' | 'USD' + +// Display status for a customer org in the partner UI. Broader than the +// backend Tenant.status enum (active/pending/suspended/deleted) — it also +// carries billing-derived states (past_due, trial, attention) that the +// partner screens distinguish. Mapping from real Tenant data happens in the +// page/composable layer. +export type CustomerStatus = 'healthy' | 'attention' | 'past_due' | 'trial' | 'suspended' + +// The shape the partner customer screens render. Originally a pure fixture +// type; retained as the view-model the table/cards bind to while real data +// is mapped onto it. +export interface CustomerOrg { + id: string + name: string + domain: string + plan: 'starter' | 'business' | 'enterprise' + planLabel: 'Starter' | 'Business' | 'Enterprise' + seats: { used: number; total: number } + health: number + status: CustomerStatus + mrrDkk: number + brandColor: string + industry: string + createdOn: string + since: string +} + +// Canonical shape of a tenant as returned by /api/partner/tenants +// (platform-api /me/partner/tenants). Previously duplicated — and subtly +// divergent — between the dashboard and customers pages. This superset is the +// single source of truth; `usePartnerTenants` returns it. +export interface PartnerTenantDoc { + _id: string + slug: string + name: string + status: 'active' | 'pending' | 'suspended' | 'deleted' + plan?: 'mvp' | 'pro' | 'enterprise' + seats?: number + // Active User docs whose tenantIds include this tenant — server-side + // aggregation, so the "used / total" seats column needs no second fetch. + userCount?: number + // Active users created in the last 30 days (for the dashboard delta). + newUserCount30d?: number + domains?: string[] + createdAt?: string + // Partner-editable customer metadata. + industry?: string + brandColor?: string + // Computed server-side (never stored): 0–100 portfolio-health score + band. + healthScore?: number + healthBand?: 'healthy' | 'watch' | 'at-risk' + provisioningStatus?: { + authentik?: 'pending' | 'ok' | 'error' | 'skipped' + stalwart?: 'pending' | 'ok' | 'error' | 'skipped' + ocis?: 'pending' | 'ok' | 'error' | 'skipped' + } +} + +// One subscription's normalized monthly amount, in minor units (e.g. 4900 = +// 49.00). `custom: true` marks Enterprise / pre-catalog subs with no priced +// amount — those render as "custom" rather than a misleading 0. +export interface MrrBreakdownRow { + tenantId: string + tenantName?: string + currency: Currency + monthlyMinor: number + custom: boolean +} + +// Response of /api/partner/mrr. Totals are grouped by currency so a partner +// with mixed-currency customers sees a per-currency total, not an FX-fudged +// single number. +export interface MrrResponse { + totals: Array<{ currency: Currency; monthlyMinor: number }> + breakdown: MrrBreakdownRow[] +}