feat(operator): inline edit mode on /partners/[slug]
Toggle the partner detail cards from read-only to editable in place. Edit button in the PageHeader flips to Cancel + Save changes; cards expose text inputs for name/domain/contact/billing, a 4-option segmented control for status, and a 0–100 range slider for marginPct. Save sends a PATCH diff (only fields that actually changed), refreshes the page data, and exits edit mode. Cancel with unsaved changes confirms first. Also tightens audit metadata: previously `Object.keys(dto)` on the ValidationPipe-instantiated DTO listed every @IsOptional() field, even when the request body didn't touch them. The partner.updated audit event now records only the keys the operator actually sent.
This commit is contained in:
@@ -21,11 +21,147 @@ const STATUS_TONE: Record<PartnerStatus, 'ok' | 'warn' | 'neutral' | 'bad'> = {
|
||||
paused: 'neutral',
|
||||
terminated: 'bad',
|
||||
}
|
||||
const STATUSES: PartnerStatus[] = ['in-negotiation', 'active', 'paused', 'terminated']
|
||||
|
||||
const TENANT_STATUS_TONE = {
|
||||
active: 'ok', pending: 'warn', suspended: 'bad', deleted: 'neutral',
|
||||
} as const
|
||||
|
||||
// ── Inline edit mode ──────────────────────────────────────────────────────
|
||||
// Cards flip from read-only display to inputs when `editing` is true. The
|
||||
// draft object shadows the live partner data; save() PATCHes only the diff
|
||||
// so backend audit metadata stays accurate (the partner.updated event lists
|
||||
// only fields the operator actually changed). Cancel discards the draft.
|
||||
|
||||
interface PartnerDraft {
|
||||
name: string
|
||||
domain: string
|
||||
status: PartnerStatus
|
||||
marginPct: number
|
||||
contactInfo: {
|
||||
primaryName: string
|
||||
primaryEmail: string
|
||||
billingEmail: string
|
||||
}
|
||||
billingInfo: {
|
||||
companyName: string
|
||||
vatId: string
|
||||
country: string
|
||||
contactEmail: string
|
||||
}
|
||||
}
|
||||
|
||||
const editing = ref(false)
|
||||
const draft = ref<PartnerDraft | null>(null)
|
||||
const saving = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
|
||||
function cloneEditable(p: Partner): PartnerDraft {
|
||||
return {
|
||||
name: p.name,
|
||||
domain: p.domain,
|
||||
status: p.status,
|
||||
marginPct: p.marginPct,
|
||||
contactInfo: {
|
||||
primaryName: p.contactInfo?.primaryName ?? '',
|
||||
primaryEmail: p.contactInfo?.primaryEmail ?? '',
|
||||
billingEmail: p.contactInfo?.billingEmail ?? '',
|
||||
},
|
||||
billingInfo: {
|
||||
companyName: p.billingInfo?.companyName ?? '',
|
||||
vatId: p.billingInfo?.vatId ?? '',
|
||||
country: p.billingInfo?.country ?? '',
|
||||
contactEmail: p.billingInfo?.contactEmail ?? '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Build a PATCH body containing only fields that differ from the server
|
||||
// state. Returns null when nothing changed. Nested objects (contactInfo /
|
||||
// billingInfo) are included whole when any child key changed — Authentik's
|
||||
// backend update path replaces nested objects rather than merging keys, so
|
||||
// sending the full subobject is the safe contract.
|
||||
function partnerDiff(orig: Partner, next: PartnerDraft): Record<string, unknown> | null {
|
||||
const out: Record<string, unknown> = {}
|
||||
if (orig.name !== next.name) out.name = next.name
|
||||
if (orig.domain !== next.domain) out.domain = next.domain
|
||||
if (orig.status !== next.status) out.status = next.status
|
||||
if (orig.marginPct !== next.marginPct) out.marginPct = next.marginPct
|
||||
const ci = orig.contactInfo ?? {}
|
||||
if (
|
||||
(ci.primaryName ?? '') !== next.contactInfo.primaryName ||
|
||||
(ci.primaryEmail ?? '') !== next.contactInfo.primaryEmail ||
|
||||
(ci.billingEmail ?? '') !== next.contactInfo.billingEmail
|
||||
) {
|
||||
out.contactInfo = next.contactInfo
|
||||
}
|
||||
const bi = orig.billingInfo ?? {}
|
||||
if (
|
||||
(bi.companyName ?? '') !== next.billingInfo.companyName ||
|
||||
(bi.vatId ?? '') !== next.billingInfo.vatId ||
|
||||
(bi.country ?? '') !== next.billingInfo.country ||
|
||||
(bi.contactEmail ?? '') !== next.billingInfo.contactEmail
|
||||
) {
|
||||
out.billingInfo = next.billingInfo
|
||||
}
|
||||
return Object.keys(out).length ? out : null
|
||||
}
|
||||
|
||||
const dirty = computed(() => {
|
||||
if (!editing.value || !draft.value || !partner.value) return false
|
||||
return partnerDiff(partner.value, draft.value) !== null
|
||||
})
|
||||
|
||||
// Client-side validation. Backend re-validates via DTO decorators — this is
|
||||
// just to keep the Save button honest about whether a submit would succeed.
|
||||
const valid = computed(() => {
|
||||
if (!draft.value) return false
|
||||
const d = draft.value
|
||||
if (d.name.length < 2 || d.name.length > 120) return false
|
||||
if (d.domain.length < 3 || d.domain.length > 120) return false
|
||||
if (d.marginPct < 0 || d.marginPct > 100 || !Number.isInteger(d.marginPct)) return false
|
||||
if (d.billingInfo.vatId.length > 40) return false
|
||||
if (d.billingInfo.country.length !== 0 && d.billingInfo.country.length !== 2) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function startEdit() {
|
||||
if (!partner.value) return
|
||||
draft.value = cloneEditable(partner.value)
|
||||
editing.value = true
|
||||
saveError.value = null
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
if (dirty.value) {
|
||||
const ok = window.confirm('Discard unsaved changes?')
|
||||
if (!ok) return
|
||||
}
|
||||
editing.value = false
|
||||
draft.value = null
|
||||
saveError.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!partner.value || !draft.value) return
|
||||
const patch = partnerDiff(partner.value, draft.value)
|
||||
if (!patch) return
|
||||
saving.value = true
|
||||
saveError.value = null
|
||||
try {
|
||||
await $fetch(`/api/partners/${slug.value}`, { method: 'PATCH', body: patch })
|
||||
await refreshPartner()
|
||||
editing.value = false
|
||||
draft.value = null
|
||||
} catch (err: unknown) {
|
||||
const e = err as { data?: { data?: { message?: string | string[] }; message?: string } }
|
||||
const m = e.data?.data?.message ?? e.data?.message
|
||||
saveError.value = Array.isArray(m) ? m.join('; ') : (m ?? 'Save failed')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Attach tenant modal ───────────────────────────────────────────────────
|
||||
const attachOpen = ref(false)
|
||||
const attachBusy = ref(false)
|
||||
@@ -117,10 +253,26 @@ async function confirmTerminate() {
|
||||
>
|
||||
<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 v-if="!editing">
|
||||
<UiButton variant="secondary">
|
||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||
Open {{ partner.domain }}
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="startEdit">
|
||||
<template #leading><UiIcon name="more" :size="13" /></template>
|
||||
Edit
|
||||
</UiButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UiButton variant="ghost" :disabled="saving" @click="cancelEdit">Cancel</UiButton>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
:disabled="!dirty || !valid || saving"
|
||||
@click="save"
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save changes' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
@@ -128,8 +280,10 @@ async function confirmTerminate() {
|
||||
<div class="grid">
|
||||
<Card>
|
||||
<h2>Contract</h2>
|
||||
<dl>
|
||||
<!-- Read-only view -->
|
||||
<dl v-if="!editing || !draft">
|
||||
<div class="dl-row"><dt>Slug</dt><dd><Mono>{{ partner.slug }}</Mono></dd></div>
|
||||
<div class="dl-row"><dt>Name</dt><dd>{{ partner.name }}</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>
|
||||
@@ -138,11 +292,54 @@ async function confirmTerminate() {
|
||||
<Mono dim>(MRR aggregation ships when Subscription gains pricing)</Mono>
|
||||
</dd></div>
|
||||
</dl>
|
||||
<!-- Edit view -->
|
||||
<dl v-else>
|
||||
<div class="dl-row"><dt>Slug</dt><dd><Mono>{{ partner.slug }}</Mono><Mono dim>(immutable)</Mono></dd></div>
|
||||
<div class="dl-row">
|
||||
<dt>Name</dt>
|
||||
<dd><input v-model="draft.name" class="field" type="text" maxlength="120" :disabled="saving" /></dd>
|
||||
</div>
|
||||
<div class="dl-row">
|
||||
<dt>Domain</dt>
|
||||
<dd><input v-model="draft.domain" class="field" type="text" maxlength="120" :disabled="saving" /></dd>
|
||||
</div>
|
||||
<div class="dl-row">
|
||||
<dt>Status</dt>
|
||||
<dd>
|
||||
<div class="seg four">
|
||||
<button
|
||||
v-for="s in STATUSES"
|
||||
:key="s"
|
||||
type="button"
|
||||
:class="{ on: draft.status === s }"
|
||||
:disabled="saving"
|
||||
@click="draft.status = s"
|
||||
>{{ s }}</button>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="dl-row">
|
||||
<dt>Margin</dt>
|
||||
<dd class="slider-cell">
|
||||
<input
|
||||
v-model.number="draft.marginPct"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
class="slider"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<Mono class="pct">{{ draft.marginPct }}%</Mono>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2>Contact</h2>
|
||||
<dl>
|
||||
<!-- Read-only view -->
|
||||
<dl v-if="!editing || !draft">
|
||||
<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>
|
||||
@@ -150,9 +347,38 @@ async function confirmTerminate() {
|
||||
<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>
|
||||
<!-- Edit view -->
|
||||
<dl v-else>
|
||||
<div class="dl-row">
|
||||
<dt>Primary</dt>
|
||||
<dd><input v-model="draft.contactInfo.primaryName" class="field" type="text" maxlength="200" :disabled="saving" /></dd>
|
||||
</div>
|
||||
<div class="dl-row">
|
||||
<dt>Email</dt>
|
||||
<dd><input v-model="draft.contactInfo.primaryEmail" class="field mono" type="email" :disabled="saving" /></dd>
|
||||
</div>
|
||||
<div class="dl-row">
|
||||
<dt>Billing</dt>
|
||||
<dd><input v-model="draft.contactInfo.billingEmail" class="field mono" type="email" :disabled="saving" /></dd>
|
||||
</div>
|
||||
<div class="dl-row">
|
||||
<dt>Company</dt>
|
||||
<dd><input v-model="draft.billingInfo.companyName" class="field" type="text" maxlength="200" :disabled="saving" /></dd>
|
||||
</div>
|
||||
<div class="dl-row">
|
||||
<dt>VAT</dt>
|
||||
<dd><input v-model="draft.billingInfo.vatId" class="field mono" type="text" maxlength="40" :disabled="saving" /></dd>
|
||||
</div>
|
||||
<div class="dl-row">
|
||||
<dt>Country</dt>
|
||||
<dd><input v-model="draft.billingInfo.country" class="field mono country" type="text" maxlength="2" placeholder="DK" :disabled="saving" /></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<p v-if="saveError" class="err inline-err">{{ saveError }}</p>
|
||||
|
||||
<Card :pad="0">
|
||||
<div class="card-head padded">
|
||||
<div>
|
||||
@@ -387,4 +613,66 @@ td.td-right { text-align: right; }
|
||||
outline: none;
|
||||
}
|
||||
.select:focus { border-color: var(--accent); }
|
||||
|
||||
/* Inline edit-mode form fields */
|
||||
.field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
outline: 0;
|
||||
}
|
||||
.field.mono { font-family: var(--font-mono); }
|
||||
.field.country { max-width: 80px; text-transform: uppercase; }
|
||||
.field:focus { border-color: var(--border-hi); }
|
||||
.field:disabled { opacity: 0.6; }
|
||||
|
||||
/* Status segmented control (mirrors FlagDetail.vue's seg pattern) */
|
||||
.seg {
|
||||
display: inline-grid;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
}
|
||||
.seg.four { grid-template-columns: repeat(4, minmax(96px, auto)); }
|
||||
.seg button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 6px 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg button:hover:not(:disabled) { color: var(--text); }
|
||||
.seg button.on { background: var(--text); color: var(--bg); }
|
||||
.seg button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* marginPct range slider (mirrors FlagDetail.vue rollout slider) */
|
||||
.slider-cell { display: flex; align-items: center; gap: 14px; width: 100%; }
|
||||
.slider { flex: 1; accent-color: var(--accent); }
|
||||
.slider:disabled { opacity: 0.6; }
|
||||
.pct { font-size: 14px; font-weight: 600; min-width: 50px; text-align: right; }
|
||||
|
||||
.inline-err {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
background: rgba(240, 88, 88, 0.08);
|
||||
border: 1px solid rgba(240, 88, 88, 0.24);
|
||||
color: var(--bad);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -92,7 +92,16 @@ export class PartnersService {
|
||||
resourceId: String(partner._id),
|
||||
resourceName: partner.name,
|
||||
partnerSlug: partner.slug,
|
||||
metadata: { changes: Object.keys(dto as Record<string, unknown>) },
|
||||
metadata: {
|
||||
// class-validator's ValidationPipe(transform: true) instantiates the
|
||||
// DTO with every @IsOptional() property defined (set to undefined
|
||||
// when absent from the body). Filtering keeps the audit log honest —
|
||||
// `changes` reflects what the operator actually sent, not the DTO
|
||||
// shape.
|
||||
changes: Object.keys(dto as Record<string, unknown>).filter(
|
||||
(k) => (dto as Record<string, unknown>)[k] !== undefined,
|
||||
),
|
||||
},
|
||||
},
|
||||
actor,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user