diff --git a/apps/operator/pages/partners/[slug].vue b/apps/operator/pages/partners/[slug].vue index bc40c77..82921b0 100644 --- a/apps/operator/pages/partners/[slug].vue +++ b/apps/operator/pages/partners/[slug].vue @@ -21,11 +21,147 @@ const STATUS_TONE: Record = { 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(null) +const saving = ref(false) +const saveError = ref(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 | null { + const out: Record = {} + 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() { > @@ -128,8 +280,10 @@ async function confirmTerminate() {

Contract

-
+ +
Slug
{{ partner.slug }}
+
Name
{{ partner.name }}
Domain
{{ partner.domain }}
Status
{{ partner.status }}
Margin
{{ partner.marginPct }}%
@@ -138,11 +292,54 @@ async function confirmTerminate() { (MRR aggregation ships when Subscription gains pricing)
+ +
+
Slug
{{ partner.slug }}(immutable)
+
+
Name
+
+
+
+
Domain
+
+
+
+
Status
+
+
+ +
+
+
+
+
Margin
+
+ + {{ draft.marginPct }}% +
+
+

Contact

-
+ +
Primary
{{ partner.contactInfo.primaryName || '—' }}
Email
{{ partner.contactInfo.primaryEmail || '—' }}
Billing
{{ partner.contactInfo.billingEmail || '—' }}
@@ -150,9 +347,38 @@ async function confirmTerminate() {
VAT
{{ partner.billingInfo.vatId || '—' }}
Country
{{ partner.billingInfo.country || '—' }}
+ +
+
+
Primary
+
+
+
+
Email
+
+
+
+
Billing
+
+
+
+
Company
+
+
+
+
VAT
+
+
+
+
Country
+
+
+
+

{{ saveError }}

+
@@ -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); +} diff --git a/services/platform-api/src/partners/partners.service.ts b/services/platform-api/src/partners/partners.service.ts index 0695706..095a632 100644 --- a/services/platform-api/src/partners/partners.service.ts +++ b/services/platform-api/src/partners/partners.service.ts @@ -92,7 +92,16 @@ export class PartnersService { resourceId: String(partner._id), resourceName: partner.name, partnerSlug: partner.slug, - metadata: { changes: Object.keys(dto as Record) }, + 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).filter( + (k) => (dto as Record)[k] !== undefined, + ), + }, }, actor, )