refactor(portal): extract shared partner types and data composables
Move partner domain types out of data/customers.ts into types/partner.ts so the fixture data exports can be removed later without breaking type imports. Add usePartnerTenants / usePartnerMrr composables wrapping the shared-key partner fetches.
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
// Pre-fills notes from the health drivers and lets the partner tweak before
|
// Pre-fills notes from the health drivers and lets the partner tweak before
|
||||||
// creating the task.
|
// creating the task.
|
||||||
|
|
||||||
import type { CustomerOrg } from '~/data/customers'
|
import type { CustomerOrg } from '~/types/partner'
|
||||||
|
|
||||||
export interface TaskContext {
|
export interface TaskContext {
|
||||||
customer: CustomerOrg
|
customer: CustomerOrg
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// customer org will be logged under their partner identity, and prompts for
|
// customer org will be logged under their partner identity, and prompts for
|
||||||
// an optional (but recommended) reason — captured into the customer audit log.
|
// 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 props = defineProps<{ customer: CustomerOrg | null }>()
|
||||||
const emit = defineEmits<{ close: []; confirm: [reason: string] }>()
|
const emit = defineEmits<{ close: []; confirm: [reason: string] }>()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// identity (not the customer's) — the design spec is explicit about this for
|
// identity (not the customer's) — the design spec is explicit about this for
|
||||||
// trust. For the prototype we just hold the customer id.
|
// 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<string | null>(null)
|
const activeCustomerId = ref<string | null>(null)
|
||||||
|
|
||||||
|
|||||||
@@ -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<MrrResponse>('/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<string, MrrBreakdownRow>()
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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<PartnerTenantDoc[]>(
|
||||||
|
'/api/partner/tenants',
|
||||||
|
{ key: 'partner-tenants', default: () => [] },
|
||||||
|
)
|
||||||
|
return { tenants, refresh, error, pending }
|
||||||
|
}
|
||||||
@@ -2,23 +2,9 @@
|
|||||||
// orgs. Numbers seeded to match partner-screens.jsx (the canonical design
|
// orgs. Numbers seeded to match partner-screens.jsx (the canonical design
|
||||||
// source) line for line: same customer set, same MRR, seats, status, mark.
|
// source) line for line: same customer set, same MRR, seats, status, mark.
|
||||||
|
|
||||||
export type CustomerStatus = 'healthy' | 'attention' | 'past_due' | 'trial' | 'suspended'
|
// 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.
|
||||||
export interface CustomerOrg {
|
import type { CustomerOrg } from '~/types/partner'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export const partner = {
|
export const partner = {
|
||||||
id: 'p-nordicmsp',
|
id: 'p-nordicmsp',
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user