Files
dezky/apps/portal/pages/partner/settings.vue
T
Ronni Baslund 0bd4e5498e feat: portal redesign, pricing catalog, partner-staff invites
- portal: new admin/ and partner/ surfaces with full component library
  (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables,
  layouts, partner-routing middleware, and supporting server APIs
- pricing: Price schema/module with operator CRUD, pricing.vue catalog UI,
  Subscription extended with cycle/currency/perSeatAmount/seats snapshots
  for stable MRR aggregation
- partner staff: User.partnerId, invite-partner-user DTO and flow,
  /partners/:slug/users endpoints, InvitePartnerUserModal, shared
  dezky-partner-staff Authentik group
- /me: partner-aware endpoint returning user + partner context so portal
  can route between end-user and partner-admin surfaces
- tenant: seats field for portfolio displays and future MRR calculations
- operator: pricing page, signed-out page, useMe/useToast composables,
  ToastStack
2026-05-28 20:00:33 +02:00

327 lines
12 KiB
Vue

<script setup lang="ts">
// Partner settings. Strict port of PartnerSettingsScreen
// (platform-partner-depth.jsx lines 858-1037). Four tabs:
// • Agreement — active reseller agreement + DefLists + documents
// • Contact info — NordicMSP company info form
// • Tax — tax/invoicing DefList + payout method card
// • Notifications — partner-level event rows with cadence + channels
const toast = useToast()
const tab = ref<'agreement' | 'contact' | 'tax' | 'notifications'>('agreement')
const tabs = [
{ value: 'agreement', label: 'Agreement' },
{ value: 'contact', label: 'Contact info' },
{ value: 'tax', label: 'Tax' },
{ value: 'notifications', label: 'Notifications' },
]
// Contact info (editable but kept simple — strict port focuses on layout)
const contact = reactive({
legalName: 'NordicMSP ApS',
tradingName: 'NordicMSP',
address: 'Vesterport 12, 1620 København V',
country: 'DK',
primaryEmail: 'partners@nordicmsp.dk',
primaryPhone: '+45 70 70 12 34',
supportHotline: '+45 70 70 12 35',
website: 'nordicmsp.dk',
})
// Documents · platform-partner-depth.jsx:922-927
const docs = [
{ n: 'Reseller agreement · v2025.11.pdf', size: '184 KB', date: '14 Nov 2025' },
{ n: 'DPA · Data Processing Addendum.pdf', size: '92 KB', date: '14 Jan 2024' },
{ n: 'Service Level Agreement.pdf', size: '64 KB', date: '14 Jan 2024' },
{ n: 'Margin schedule · v2025.11.xlsx', size: '24 KB', date: '14 Nov 2025' },
]
// Notifications · platform-partner-depth.jsx:1013-1020
const events = [
{ event: 'New customer signed up', when: 'immediate', channels: 'email · chat' },
{ event: 'Customer past-due invoice', when: 'immediate', channels: 'email · in-app' },
{ event: 'Customer approaching limit', when: 'daily', channels: 'email' },
{ event: 'Customer downgrade or churn', when: 'immediate', channels: 'email · chat · in-app' },
{ event: 'Payout processed', when: 'immediate', channels: 'email' },
{ event: 'New ticket from a customer', when: 'immediate', channels: 'chat' },
{ event: 'Dezky agreement change', when: 'immediate', channels: 'email' },
]
</script>
<template>
<div>
<PageHeader
eyebrow="Partner"
title="Partner settings"
subtitle="Agreement terms, business details, tax setup, and partner-level notifications."
/>
<div class="tabs-wrap">
<Tabs v-model="tab" :items="tabs" />
</div>
<div class="content">
<!-- AGREEMENT -->
<template v-if="tab === 'agreement'">
<Card>
<div class="card-head">
<div>
<Eyebrow>Reseller agreement</Eyebrow>
<div class="card-title">Active · v2025.11</div>
<p class="sub">Effective since 14 Jan 2024 · auto-renews 14 Jan 2027</p>
</div>
<div class="head-actions">
<UiButton size="sm" variant="secondary" @click="toast.ok('Downloading', 'Reseller agreement v2025.11')">
<template #leading><UiIcon name="download" :size="13" /></template>
Download PDF
</UiButton>
<UiButton size="sm" variant="ghost" @click="toast.info('Version history', 'Showing all 3 versions')">View history</UiButton>
</div>
</div>
<div class="agree-grid">
<dl class="def">
<div><dt>Tier</dt><dd>Tier 2 · Established</dd></div>
<div><dt>Default margin</dt><dd>20% on Starter &amp; Business</dd></div>
<div><dt>Enterprise margin</dt><dd>Negotiated · 15% baseline</dd></div>
<div><dt>Volume rebate</dt><dd>+2% over 200 active seats · qualifies</dd></div>
<div><dt>Payout cadence</dt><dd>Monthly · 3rd business day</dd></div>
<div><dt>Min commitment</dt><dd>5 active customers</dd></div>
</dl>
<dl class="def">
<div><dt>Effective</dt><dd>14 Jan 2024</dd></div>
<div><dt>Term</dt><dd>36 months · auto-renew</dd></div>
<div><dt>Notice period</dt><dd>90 days written</dd></div>
<div><dt>Liability cap</dt><dd>12 months of fees</dd></div>
<div><dt>Governing law</dt><dd>Denmark · Copenhagen</dd></div>
<div><dt>Signed by</dt><dd>Anne Baslund · NordicMSP</dd></div>
</dl>
</div>
</Card>
<Card>
<Eyebrow>Documents</Eyebrow>
<div class="card-title">Related files</div>
<div class="doc-list">
<button
v-for="d in docs"
:key="d.n"
class="doc-row"
@click="toast.info('Downloading', d.n)"
>
<UiIcon name="file" :size="14" />
<span class="dr-name">{{ d.n }}</span>
<Mono dim>{{ d.size }} · {{ d.date }}</Mono>
<UiIcon name="download" :size="13" />
</button>
</div>
</Card>
</template>
<!-- CONTACT INFO -->
<template v-if="tab === 'contact'">
<Card>
<div class="card-head">
<div>
<Eyebrow>Business</Eyebrow>
<div class="card-title">NordicMSP company info</div>
</div>
<UiButton size="sm" variant="ghost" @click="toast.ok('Saved', 'Contact info updated')">Edit</UiButton>
</div>
<div class="contact-grid">
<div class="col">
<label class="field"><Eyebrow>Legal name</Eyebrow><input v-model="contact.legalName" /></label>
<label class="field"><Eyebrow>Trading name</Eyebrow><input v-model="contact.tradingName" /></label>
<label class="field"><Eyebrow>Address</Eyebrow><input v-model="contact.address" /></label>
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="contact.country" /></label>
</div>
<div class="col">
<label class="field"><Eyebrow>Primary contact · email</Eyebrow><input v-model="contact.primaryEmail" /></label>
<label class="field"><Eyebrow>Primary contact · phone</Eyebrow><input v-model="contact.primaryPhone" /></label>
<label class="field"><Eyebrow>Support hotline</Eyebrow><input v-model="contact.supportHotline" /></label>
<label class="field"><Eyebrow>Public website</Eyebrow><input v-model="contact.website" /></label>
</div>
</div>
</Card>
</template>
<!-- TAX -->
<template v-if="tab === 'tax'">
<Card>
<Eyebrow>Identification</Eyebrow>
<div class="card-title">Tax &amp; invoicing</div>
<div class="tax-grid">
<dl class="def">
<div><dt>Country</dt><dd>Denmark</dd></div>
<div><dt>CVR</dt><dd>41 88 22 04</dd></div>
<div><dt>VAT number</dt><dd>DK 41 88 22 04</dd></div>
<div><dt>VAT rate</dt><dd>25% · standard DK</dd></div>
</dl>
<dl class="def">
<div><dt>Currency</dt><dd>DKK · EUR available</dd></div>
<div><dt>Invoicing</dt><dd>OIOUBL · NemHandel</dd></div>
<div><dt>EAN/GLN</dt><dd>5790000123456</dd></div>
<div><dt>Tax exempt</dt><dd>No</dd></div>
</dl>
</div>
</Card>
<Card>
<div class="card-head">
<div>
<Eyebrow>Payout method</Eyebrow>
<div class="card-title">Where Dezky pays your margin</div>
</div>
<UiButton size="sm" variant="ghost" @click="toast.info('Change payout method', 'Contact partner success to switch')">Change</UiButton>
</div>
<div class="payout-row">
<div class="payout-icon"><UiIcon name="card" :size="20" /></div>
<div class="payout-meta">
<div class="payout-bank">Danske Bank · ApS account</div>
<Mono dim>IBAN DK 1820 · BIC DABADKKK</Mono>
</div>
<Badge tone="ok" dot>verified</Badge>
</div>
</Card>
</template>
<!-- NOTIFICATIONS -->
<template v-if="tab === 'notifications'">
<Card :pad="0">
<div class="card-head pad">
<div>
<Eyebrow>Partner-level events</Eyebrow>
<div class="card-title">Where to send each event</div>
</div>
</div>
<div class="notif-list">
<div
v-for="(row, i) in events"
:key="row.event"
class="notif-row"
:class="{ last: i === events.length - 1 }"
>
<span class="notif-event">{{ row.event }}</span>
<Mono>{{ row.when }}</Mono>
<Mono dim>{{ row.channels }}</Mono>
<UiButton size="sm" variant="ghost" @click="toast.info('Edit notification', row.event)">Edit</UiButton>
</div>
</div>
</Card>
</template>
</div>
</div>
</template>
<style scoped>
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
.content { padding: 20px 40px 64px; display: flex; flex-direction: column; gap: 16px; max-width: 1000px; }
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.card-head.pad {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 0;
}
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
margin-top: 4px;
}
.sub { font-size: 13px; color: var(--text-mute); margin: 6px 0 0; line-height: 1.5; }
.head-actions { display: flex; gap: 6px; flex-shrink: 0; }
.agree-grid, .tax-grid, .contact-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
/* DefList */
.def { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; }
.def div { display: grid; grid-template-columns: 160px 1fr; gap: 12px; font-size: 13px; align-items: center; }
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
.def dd { margin: 0; }
/* Documents */
.doc-list { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
.doc-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 6px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
cursor: pointer;
text-align: left;
}
.doc-row:hover { background: var(--row-hover); }
.doc-row :deep(svg) { color: var(--text-mute); flex-shrink: 0; }
.dr-name { flex: 1; font-weight: 500; }
/* Contact form */
.contact-grid .col { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field input {
padding: 9px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field input:focus { outline: none; border-color: var(--border-hi); }
/* Payout */
.payout-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
margin-top: 8px;
}
.payout-icon {
width: 44px;
height: 44px;
border-radius: 8px;
background: var(--text);
color: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.payout-meta { flex: 1; min-width: 0; }
.payout-bank { font-size: 14px; font-weight: 500; }
/* Notifications */
.notif-list { padding: 8px; }
.notif-row {
display: grid;
grid-template-columns: 1fr 120px 220px 80px;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.notif-row.last { border-bottom: none; }
.notif-event { font-size: 13px; font-weight: 500; }
</style>