0bd4e5498e
- 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
327 lines
12 KiB
Vue
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 & 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 & 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>
|