feat(operator): partner management with attach/detach (O.6)

- Partners list with name/domain/status/customers/margin + Create modal
- Partner detail: contract card, contact card, customers table, attach modal,
  terminate (soft-delete) danger card
- Operator proxies for /partners + /partners/:slug/tenants
- platform-api: add partnerId Prop to Tenant schema. The field was being
  silently dropped by Mongoose because the schema didn't declare it.
- tenants.service: rewrite update() to build $set/$unset explicitly and cast
  partnerId via new Types.ObjectId(). Handles detach via $unset so the field
  vanishes from the doc cleanly.
This commit is contained in:
Ronni Baslund
2026-05-24 08:02:00 +02:00
parent 8e81730372
commit fbbb43e3e2
12 changed files with 807 additions and 7 deletions
+390
View File
@@ -0,0 +1,390 @@
<script setup lang="ts">
import type { Partner, PartnerStatus } from '~/types/partner'
import type { Tenant } from '~/types/tenant'
const route = useRoute()
const slug = computed(() => route.params.slug as string)
const { data: partner, refresh: refreshPartner } = await useFetch<Partner>(
() => `/api/partners/${slug.value}`,
{ watch: [slug] },
)
const { data: customers, refresh: refreshCustomers } = await useFetch<Tenant[]>(
() => `/api/partners/${slug.value}/tenants`,
{ default: () => [], watch: [slug] },
)
const STATUS_TONE: Record<PartnerStatus, 'ok' | 'warn' | 'neutral' | 'bad'> = {
active: 'ok',
'in-negotiation': 'warn',
paused: 'neutral',
terminated: 'bad',
}
const TENANT_STATUS_TONE = {
active: 'ok', pending: 'warn', suspended: 'bad', deleted: 'neutral',
} as const
// ── Attach tenant modal ───────────────────────────────────────────────────
const attachOpen = ref(false)
const attachBusy = ref(false)
const attachError = ref<string | null>(null)
const selectedSlug = ref('')
const { data: allTenants } = await useFetch<Tenant[]>('/api/tenants', { default: () => [] })
const attachable = computed(() => {
return (allTenants.value ?? []).filter(
(t) => !t.partnerId && t.status !== 'deleted',
)
})
function openAttach() {
selectedSlug.value = attachable.value[0]?.slug ?? ''
attachError.value = null
attachOpen.value = true
}
async function submitAttach() {
if (!selectedSlug.value || !partner.value) return
attachBusy.value = true
attachError.value = null
try {
await $fetch(`/api/tenants/${selectedSlug.value}`, {
method: 'PATCH',
body: { partnerId: partner.value._id },
})
attachOpen.value = false
await Promise.all([refreshPartner(), refreshCustomers()])
} catch (err: unknown) {
const e = err as { data?: { data?: { message?: string }; message?: string } }
attachError.value = e.data?.data?.message ?? e.data?.message ?? String(err)
} finally {
attachBusy.value = false
}
}
// ── Detach confirm ────────────────────────────────────────────────────────
const detachTarget = ref<Tenant | null>(null)
const detachBusy = ref(false)
const detachError = ref<string | null>(null)
async function confirmDetach() {
if (!detachTarget.value) return
detachBusy.value = true
detachError.value = null
try {
await $fetch(`/api/tenants/${detachTarget.value.slug}`, {
method: 'PATCH',
body: { partnerId: null },
})
detachTarget.value = null
await Promise.all([refreshPartner(), refreshCustomers()])
} catch (err: unknown) {
const e = err as { data?: { data?: { message?: string }; message?: string } }
detachError.value = e.data?.data?.message ?? e.data?.message ?? String(err)
} finally {
detachBusy.value = false
}
}
// ── Soft-terminate partner ────────────────────────────────────────────────
const terminateOpen = ref(false)
const terminateBusy = ref(false)
const terminateError = ref<string | null>(null)
async function confirmTerminate() {
terminateBusy.value = true
terminateError.value = null
try {
await $fetch(`/api/partners/${slug.value}`, { method: 'DELETE' })
await navigateTo('/partners')
} catch (err: unknown) {
const e = err as { data?: { data?: { message?: string }; message?: string } }
terminateError.value = e.data?.data?.message ?? e.data?.message ?? String(err)
terminateBusy.value = false
}
}
</script>
<template>
<div v-if="partner">
<PageHeader
:eyebrow="`Partner · ${partner.slug}`"
:title="partner.name"
:subtitle="`${partner.customers} customer(s) · ${partner.marginPct}% revenue share · partner since ${partner.partnershipStartedAt ? new Date(partner.partnershipStartedAt).toISOString().slice(0, 10) : new Date(partner.createdAt).toISOString().slice(0, 10)}`"
>
<template #actions>
<Badge :tone="STATUS_TONE[partner.status]" dot>{{ partner.status }}</Badge>
<UiButton variant="secondary">
<template #leading><UiIcon name="external" :size="13" /></template>
Open {{ partner.domain }}
</UiButton>
</template>
</PageHeader>
<div class="stage">
<div class="grid">
<Card>
<h2>Contract</h2>
<dl>
<div class="dl-row"><dt>Slug</dt><dd><Mono>{{ partner.slug }}</Mono></dd></div>
<div class="dl-row"><dt>Domain</dt><dd><Mono>{{ partner.domain }}</Mono></dd></div>
<div class="dl-row"><dt>Status</dt><dd><Badge :tone="STATUS_TONE[partner.status]" dot>{{ partner.status }}</Badge></dd></div>
<div class="dl-row"><dt>Margin</dt><dd><Mono>{{ partner.marginPct }}%</Mono></dd></div>
<div class="dl-row"><dt>Customers</dt><dd>
<span class="num">{{ partner.customers }}</span>
<Mono dim>(MRR aggregation ships when Subscription gains pricing)</Mono>
</dd></div>
</dl>
</Card>
<Card>
<h2>Contact</h2>
<dl>
<div class="dl-row"><dt>Primary</dt><dd>{{ partner.contactInfo.primaryName || '—' }}</dd></div>
<div class="dl-row"><dt>Email</dt><dd><Mono :dim="!partner.contactInfo.primaryEmail">{{ partner.contactInfo.primaryEmail || '—' }}</Mono></dd></div>
<div class="dl-row"><dt>Billing</dt><dd><Mono :dim="!partner.contactInfo.billingEmail">{{ partner.contactInfo.billingEmail || '—' }}</Mono></dd></div>
<div class="dl-row"><dt>Company</dt><dd>{{ partner.billingInfo.companyName || '—' }}</dd></div>
<div class="dl-row"><dt>VAT</dt><dd><Mono :dim="!partner.billingInfo.vatId">{{ partner.billingInfo.vatId || '—' }}</Mono></dd></div>
<div class="dl-row"><dt>Country</dt><dd><Mono :dim="!partner.billingInfo.country">{{ partner.billingInfo.country || '—' }}</Mono></dd></div>
</dl>
</Card>
</div>
<Card :pad="0">
<div class="card-head padded">
<div>
<h2>Customers</h2>
<p class="hint">Tenants whose <code>partnerId</code> points at this partner.</p>
</div>
<UiButton variant="primary" :disabled="attachable.length === 0" @click="openAttach">
<template #leading><UiIcon name="plus" :size="13" /></template>
Attach tenant
</UiButton>
</div>
<table>
<thead>
<tr>
<th>Tenant</th><th>Status</th><th>Plan</th><th>Domains</th><th class="th-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="(customers ?? []).length === 0" class="empty">
<td colspan="5">
<span class="empty-inner">No customers under this partner yet.</span>
</td>
</tr>
<tr v-for="t in (customers ?? [])" :key="t._id">
<td>
<NuxtLink class="link" :to="`/tenants/${t.slug}`">
<div class="cell-name">{{ t.name }}</div>
<Mono dim>{{ t.slug }}</Mono>
</NuxtLink>
</td>
<td><Badge :tone="TENANT_STATUS_TONE[t.status]" dot>{{ t.status }}</Badge></td>
<td><Badge tone="neutral">{{ t.plan }}</Badge></td>
<td><Mono :dim="!t.domains.length">{{ t.domains[0] || '—' }}</Mono></td>
<td class="td-right">
<UiButton variant="danger" size="sm" @click="detachTarget = t; detachError = null">Detach</UiButton>
</td>
</tr>
</tbody>
</table>
</Card>
<Card>
<h2 class="danger">Soft-terminate partner</h2>
<p>
Marks the partner as <Mono>terminated</Mono>. Customer tenants keep
their <Mono>partnerId</Mono> reference we never hard-delete since
historical billing depends on it. To re-activate, edit the status
back to active via the admin API (UI editor lands later).
</p>
<UiButton
variant="danger"
:disabled="partner.status === 'terminated'"
@click="terminateOpen = true; terminateError = null"
>Terminate partner</UiButton>
</Card>
</div>
<!-- Attach tenant modal -->
<ConfirmDialog
:open="attachOpen"
:eyebrow="`Partner · ${partner.slug}`"
title="Attach a tenant"
confirm-label="Attach"
:busy="attachBusy"
@close="attachOpen = false"
@confirm="submitAttach"
>
<p>Pick an unattached tenant. Direct customers (those without a partner) show below.</p>
<select v-if="attachable.length" v-model="selectedSlug" class="select">
<option v-for="t in attachable" :key="t._id" :value="t.slug">
{{ t.name }} {{ t.slug }} · {{ t.plan }}
</option>
</select>
<p v-else class="hint">No unattached tenants. Detach one from its current partner first.</p>
<p v-if="attachError" class="err">{{ attachError }}</p>
</ConfirmDialog>
<!-- Detach confirm -->
<ConfirmDialog
:open="detachTarget !== null"
:eyebrow="`Partner · ${partner.slug}`"
title="Detach this tenant?"
confirm-label="Detach"
tone="danger"
:busy="detachBusy"
@close="detachTarget = null"
@confirm="confirmDetach"
>
<p>
Remove <strong>{{ detachTarget?.name }}</strong> from <strong>{{ partner.name }}</strong>.
The tenant becomes a direct customer (no partner). Reversible re-attach any time.
</p>
<p v-if="detachError" class="err">{{ detachError }}</p>
</ConfirmDialog>
<!-- Terminate partner confirm -->
<ConfirmDialog
:open="terminateOpen"
:eyebrow="`Partner · ${partner.slug}`"
title="Terminate this partner?"
confirm-label="Terminate"
tone="danger"
:busy="terminateBusy"
@close="terminateOpen = false"
@confirm="confirmTerminate"
>
<p>
Set <strong>{{ partner.name }}</strong> status to <Mono>terminated</Mono>. Existing
customer tenants keep their reference. To re-activate later, set status back via the
admin API.
</p>
<p v-if="terminateError" class="err">{{ terminateError }}</p>
</ConfirmDialog>
</div>
</template>
<style scoped>
.stage {
padding: 24px 40px 64px 40px;
display: flex;
flex-direction: column;
gap: 16px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 16px;
}
h2 {
font-family: var(--font-display);
font-weight: 600;
font-size: 16px;
letter-spacing: -0.01em;
margin: 0 0 16px 0;
}
h2.danger { color: var(--bad); }
p {
margin: 0 0 14px 0;
color: var(--text-dim);
font-size: 13px;
line-height: 1.55;
}
p.hint { font-size: 12px; color: var(--text-mute); margin: 4px 0 0 0; }
p.err { color: var(--bad); font-family: var(--font-mono); font-size: 12px; margin-top: 12px; }
code {
font-family: var(--font-mono);
font-size: 12px;
background: rgba(244, 243, 238, 0.06);
padding: 1px 6px;
border-radius: 3px;
}
dl { display: flex; flex-direction: column; gap: 10px; }
.dl-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 12px;
font-size: 13px;
}
dt {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
align-self: center;
}
dd { margin: 0; color: var(--text); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.num {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
letter-spacing: -0.01em;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.card-head.padded { padding: 20px 24px 12px 24px; }
.card-head h2 { margin: 0; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead tr { border-bottom: 1px solid var(--border); }
th {
padding: 12px 16px;
text-align: left;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
}
th.th-right { text-align: right; }
tbody tr { border-bottom: 1px solid var(--border); }
tbody tr:last-child { border-bottom: none; }
td { padding: 14px 16px; color: var(--text); }
td.td-right { text-align: right; }
.empty td { padding: 36px 16px; text-align: center; }
.empty-inner { color: var(--text-mute); font-size: 13px; }
.link {
display: flex;
flex-direction: column;
gap: 2px;
text-decoration: none;
color: var(--text);
}
.link:hover .cell-name { text-decoration: underline; }
.cell-name { font-weight: 500; }
.select {
width: 100%;
height: 36px;
padding: 0 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
}
.select:focus { border-color: var(--accent); }
</style>