114b419a69
Every page header's Refresh button rendered a downward chevron because the icon set had no refresh glyph. Added a circular-arrow 'refresh' icon to UiIcon and pointed all seven Refresh buttons (Overview, Tenants, Partners, Users, Operator team, Audit, Infrastructure) at it.
318 lines
8.8 KiB
Vue
318 lines
8.8 KiB
Vue
<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="refresh" :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>
|