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:
@@ -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>
|
||||
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
import type { Partner, PartnerStatus } from '~/types/partner'
|
||||
|
||||
const { data: partners, refresh, pending } = await useFetch<Partner[]>('/api/partners', {
|
||||
default: () => [],
|
||||
})
|
||||
|
||||
const search = ref('')
|
||||
const statusFilter = ref<'all' | PartnerStatus>('all')
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
return (partners.value ?? []).filter((p) => {
|
||||
if (statusFilter.value !== 'all' && p.status !== statusFilter.value) return false
|
||||
if (!q) return true
|
||||
return p.slug.toLowerCase().includes(q) || p.name.toLowerCase().includes(q)
|
||||
})
|
||||
})
|
||||
|
||||
const counts = computed(() => {
|
||||
const c = { all: 0, active: 0, 'in-negotiation': 0, paused: 0, terminated: 0 }
|
||||
for (const p of partners.value ?? []) {
|
||||
c.all++
|
||||
c[p.status]++
|
||||
}
|
||||
return c
|
||||
})
|
||||
|
||||
const STATUS_TONE: Record<PartnerStatus, 'ok' | 'warn' | 'neutral' | 'bad'> = {
|
||||
active: 'ok',
|
||||
'in-negotiation': 'warn',
|
||||
paused: 'neutral',
|
||||
terminated: 'bad',
|
||||
}
|
||||
|
||||
// ── Create modal ──────────────────────────────────────────────────────────
|
||||
const createOpen = ref(false)
|
||||
const createBusy = ref(false)
|
||||
const createError = ref<string | null>(null)
|
||||
const form = reactive({
|
||||
slug: '',
|
||||
name: '',
|
||||
domain: '',
|
||||
marginPct: 20,
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
form.slug = ''
|
||||
form.name = ''
|
||||
form.domain = ''
|
||||
form.marginPct = 20
|
||||
createError.value = null
|
||||
createOpen.value = true
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
createBusy.value = true
|
||||
createError.value = null
|
||||
try {
|
||||
const created = await $fetch<Partner>('/api/partners', { method: 'POST', body: form })
|
||||
createOpen.value = false
|
||||
await refresh()
|
||||
await navigateTo(`/partners/${created.slug}`)
|
||||
} catch (err: unknown) {
|
||||
const e = err as { data?: { data?: { message?: string }; message?: string }; statusCode?: number }
|
||||
createError.value = e.data?.data?.message ?? e.data?.message ?? String(err)
|
||||
} finally {
|
||||
createBusy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Commercial"
|
||||
title="Partners"
|
||||
:subtitle="`${counts.all} partners — ${counts.active} active, ${counts['in-negotiation']} in negotiation.`"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="pending" @click="refresh()">
|
||||
<template #leading><UiIcon name="chevDown" :size="13" /></template>
|
||||
Refresh
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="openCreate">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
New partner
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="stage">
|
||||
<div class="filters">
|
||||
<div class="search">
|
||||
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
||||
<input v-model="search" placeholder="Search slug or name…" />
|
||||
</div>
|
||||
<div class="chips">
|
||||
<button
|
||||
v-for="opt in (['all', 'active', 'in-negotiation', 'paused', 'terminated'] as const)"
|
||||
:key="opt"
|
||||
:class="['chip', { active: statusFilter === opt }]"
|
||||
@click="statusFilter = opt"
|
||||
>
|
||||
{{ opt }}
|
||||
<span class="chip-count">{{ counts[opt] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Partner</th>
|
||||
<th>Status</th>
|
||||
<th>Domain</th>
|
||||
<th class="th-right">Customers</th>
|
||||
<th class="th-right">Margin</th>
|
||||
<th>Since</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="filtered.length === 0" class="empty">
|
||||
<td colspan="6">
|
||||
<div class="empty-inner">
|
||||
<UiIcon name="briefcase" :size="20" stroke="var(--text-mute)" />
|
||||
<span>No partners match this filter.</span>
|
||||
<UiButton v-if="counts.all === 0" variant="ghost" size="sm" @click="openCreate">Create the first one</UiButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="p in filtered" :key="p._id" class="clickable" @click="navigateTo(`/partners/${p.slug}`)">
|
||||
<td>
|
||||
<div class="cell-tenant">
|
||||
<div class="cell-name">{{ p.name }}</div>
|
||||
<Mono dim>{{ p.slug }}</Mono>
|
||||
</div>
|
||||
</td>
|
||||
<td><Badge :tone="STATUS_TONE[p.status]" dot>{{ p.status }}</Badge></td>
|
||||
<td><Mono dim>{{ p.domain }}</Mono></td>
|
||||
<td class="td-right">
|
||||
<span class="num">{{ p.customers }}</span>
|
||||
</td>
|
||||
<td class="td-right">
|
||||
<Mono dim>{{ p.marginPct }}%</Mono>
|
||||
</td>
|
||||
<td><Mono dim>{{ new Date(p.createdAt).toISOString().slice(0, 10) }}</Mono></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="createOpen"
|
||||
eyebrow="New partner"
|
||||
title="Create reseller partner"
|
||||
confirm-label="Create"
|
||||
:busy="createBusy"
|
||||
@close="createOpen = false"
|
||||
@confirm="submitCreate"
|
||||
>
|
||||
<form class="form" @submit.prevent="submitCreate">
|
||||
<label>
|
||||
<span>Slug · URL-safe id</span>
|
||||
<input v-model="form.slug" placeholder="e.g. nordicmsp" autocomplete="off" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input v-model="form.name" placeholder="e.g. NordicMSP" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Partner domain</span>
|
||||
<input v-model="form.domain" placeholder="e.g. nordicmsp.dk" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Revenue share (%)</span>
|
||||
<input v-model.number="form.marginPct" type="number" min="0" max="100" />
|
||||
</label>
|
||||
</form>
|
||||
<p v-if="createError" class="err">{{ createError }}</p>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stage {
|
||||
padding: 24px 40px 64px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
}
|
||||
.search input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chips { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:hover { background: var(--elevated); color: var(--text); }
|
||||
.chip.active { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||
.chip-count { font-size: 10px; opacity: 0.6; }
|
||||
|
||||
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; }
|
||||
tbody tr.clickable { cursor: pointer; }
|
||||
tbody tr.clickable:hover { background: var(--surface); }
|
||||
|
||||
td { padding: 14px 16px; color: var(--text); }
|
||||
td.td-right { text-align: right; }
|
||||
|
||||
.cell-tenant { display: flex; flex-direction: column; gap: 2px; }
|
||||
.cell-name { font-weight: 500; }
|
||||
|
||||
.empty td { padding: 48px 16px; text-align: center; }
|
||||
.empty-inner {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-mute);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.num {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.form label { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form label span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
font-weight: 500;
|
||||
}
|
||||
.form input {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.form input:focus { border-color: var(--accent); }
|
||||
|
||||
.err {
|
||||
margin: 12px 0 0 0;
|
||||
color: var(--bad);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { platformApi } from '~~/server/utils/platform-api'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
await platformApi(event, `/partners/${slug}`, { method: 'DELETE' })
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { platformApi } from '~~/server/utils/platform-api'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
return platformApi(event, `/partners/${slug}`)
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { platformApi } from '~~/server/utils/platform-api'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
const body = await readBody(event)
|
||||
return platformApi(event, `/partners/${slug}`, { method: 'PATCH', body })
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { platformApi } from '~~/server/utils/platform-api'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
return platformApi(event, `/partners/${slug}/tenants`)
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
import { platformApi } from '~~/server/utils/platform-api'
|
||||
|
||||
export default defineEventHandler(async (event) => platformApi(event, '/partners'))
|
||||
@@ -0,0 +1,6 @@
|
||||
import { platformApi } from '~~/server/utils/platform-api'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
return platformApi(event, '/partners', { method: 'POST', body })
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
// Shape returned by /api/partners — matches Partner schema on platform-api,
|
||||
// plus the `customers` count aggregated at the controller layer.
|
||||
|
||||
export type PartnerStatus = 'active' | 'in-negotiation' | 'paused' | 'terminated'
|
||||
|
||||
export interface Partner {
|
||||
_id: string
|
||||
slug: string
|
||||
name: string
|
||||
domain: string
|
||||
status: PartnerStatus
|
||||
marginPct: number
|
||||
partnershipStartedAt?: string
|
||||
contactInfo: {
|
||||
primaryName?: string
|
||||
primaryEmail?: string
|
||||
billingEmail?: string
|
||||
}
|
||||
billingInfo: {
|
||||
companyName?: string
|
||||
vatId?: string
|
||||
country?: string
|
||||
contactEmail?: string
|
||||
}
|
||||
customers: number // computed at query time
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
+13
-6
@@ -415,13 +415,20 @@ forward as bearer to platform-api.
|
||||
- Nitro can throw `Could not load /app/server/api/...` if Vite picks
|
||||
up a new file mid-build. Container restart clears it.
|
||||
|
||||
### O.6 · Partner management (real backend)
|
||||
### O.6 · Partner management (real backend) ✓
|
||||
|
||||
- [ ] `pages/partners/index.vue` — list with name/domain/status/customers/MRR
|
||||
- [ ] `pages/partners/[slug].vue` — detail panel with customers list,
|
||||
MRR breakdown, margin, contact info
|
||||
- [ ] "Create partner" modal — POST /partners
|
||||
- [ ] Attach / detach tenant to partner (PATCH on tenant.partnerId)
|
||||
- [x] `pages/partners/index.vue` — list with name/domain/status/customers/margin
|
||||
- [x] `pages/partners/[slug].vue` — detail panel with contract card, contact
|
||||
card, customers table, attach modal, terminate danger card
|
||||
- [x] "Create partner" modal — POST /partners, navigates to detail on success
|
||||
- [x] Attach / detach tenant to partner (PATCH on tenant.partnerId, with $unset
|
||||
for detach so the field disappears cleanly)
|
||||
- [x] `services/platform-api/src/schemas/tenant.schema.ts` — added the
|
||||
`partnerId` Prop. It was missing, which is why early PATCH attempts
|
||||
returned 200 but Mongoose silently dropped the field. Smoke-tested with
|
||||
acme ⇄ nordicmsp and a throwaway temp-msp partner (created + terminated).
|
||||
- MRR aggregation deferred until Subscription gains real pricing (see
|
||||
follow-ups). For now `customers` is just a count of attached tenants.
|
||||
|
||||
### O.7 · Visual-only screens (mock fixtures)
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ export class Tenant {
|
||||
@Prop({ type: [String], default: [] })
|
||||
domains!: string[]
|
||||
|
||||
// Optional MSP/reseller this tenant belongs to. Sparse — direct tenants have none.
|
||||
@Prop({ type: Types.ObjectId, ref: 'Partner', index: true, sparse: true })
|
||||
partnerId?: Types.ObjectId
|
||||
|
||||
// External system handles — filled in by the provisioning worker (Phase 4)
|
||||
@Prop({ index: true, sparse: true })
|
||||
authentikGroupId?: string
|
||||
|
||||
@@ -62,8 +62,27 @@ export class TenantsService {
|
||||
}
|
||||
|
||||
async update(slug: string, dto: UpdateTenantDto): Promise<TenantDocument> {
|
||||
// Build $set / $unset explicitly. Doing `findOneAndUpdate({slug}, dto, ...)`
|
||||
// with a class-transformer instance leaks undefined slots into the update,
|
||||
// and Mongoose doesn't always cast string→ObjectId for ref fields when
|
||||
// wrapped that way. Explicit $set keeps the intent clear and handles the
|
||||
// detach case (`partnerId: null`) cleanly via $unset.
|
||||
const set: Record<string, unknown> = {}
|
||||
const unset: Record<string, ''> = {}
|
||||
if (dto.name !== undefined) set.name = dto.name
|
||||
if (dto.status !== undefined) set.status = dto.status
|
||||
if (dto.plan !== undefined) set.plan = dto.plan
|
||||
if (dto.domains !== undefined) set.domains = dto.domains
|
||||
if (dto.partnerId !== undefined) {
|
||||
if (dto.partnerId === null) unset.partnerId = ''
|
||||
else set.partnerId = new Types.ObjectId(dto.partnerId)
|
||||
}
|
||||
const update: Record<string, unknown> = {}
|
||||
if (Object.keys(set).length) update.$set = set
|
||||
if (Object.keys(unset).length) update.$unset = unset
|
||||
|
||||
const tenant = await this.tenantModel
|
||||
.findOneAndUpdate({ slug }, dto, { new: true, runValidators: true })
|
||||
.findOneAndUpdate({ slug }, update, { new: true, runValidators: true })
|
||||
.exec()
|
||||
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
|
||||
return tenant
|
||||
|
||||
Reference in New Issue
Block a user