4a1a4ddad5
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.
679 lines
23 KiB
Vue
679 lines
23 KiB
Vue
<script setup lang="ts">
|
|
import type { Partner, PartnerStatus } from '~/types/partner'
|
|
import type { Tenant } from '~/types/tenant'
|
|
|
|
const route = useRoute()
|
|
const slug = computed(() => route.params.slug as string)
|
|
|
|
const { data: partner, refresh: refreshPartner } = await useFetch<Partner>(
|
|
() => `/api/partners/${slug.value}`,
|
|
{ watch: [slug] },
|
|
)
|
|
|
|
const { data: customers, refresh: refreshCustomers } = await useFetch<Tenant[]>(
|
|
() => `/api/partners/${slug.value}/tenants`,
|
|
{ default: () => [], watch: [slug] },
|
|
)
|
|
|
|
const STATUS_TONE: Record<PartnerStatus, 'ok' | 'warn' | 'neutral' | 'bad'> = {
|
|
active: 'ok',
|
|
'in-negotiation': 'warn',
|
|
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)
|
|
const attachError = ref<string | null>(null)
|
|
const selectedSlug = ref('')
|
|
|
|
const { data: allTenants } = await useFetch<Tenant[]>('/api/tenants', { default: () => [] })
|
|
|
|
const attachable = computed(() => {
|
|
return (allTenants.value ?? []).filter(
|
|
(t) => !t.partnerId && t.status !== 'deleted',
|
|
)
|
|
})
|
|
|
|
function openAttach() {
|
|
selectedSlug.value = attachable.value[0]?.slug ?? ''
|
|
attachError.value = null
|
|
attachOpen.value = true
|
|
}
|
|
|
|
async function submitAttach() {
|
|
if (!selectedSlug.value || !partner.value) return
|
|
attachBusy.value = true
|
|
attachError.value = null
|
|
try {
|
|
await $fetch(`/api/tenants/${selectedSlug.value}`, {
|
|
method: 'PATCH',
|
|
body: { partnerId: partner.value._id },
|
|
})
|
|
attachOpen.value = false
|
|
await Promise.all([refreshPartner(), refreshCustomers()])
|
|
} catch (err: unknown) {
|
|
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
|
attachError.value = e.data?.data?.message ?? e.data?.message ?? String(err)
|
|
} finally {
|
|
attachBusy.value = false
|
|
}
|
|
}
|
|
|
|
// ── Detach confirm ────────────────────────────────────────────────────────
|
|
const detachTarget = ref<Tenant | null>(null)
|
|
const detachBusy = ref(false)
|
|
const detachError = ref<string | null>(null)
|
|
|
|
async function confirmDetach() {
|
|
if (!detachTarget.value) return
|
|
detachBusy.value = true
|
|
detachError.value = null
|
|
try {
|
|
await $fetch(`/api/tenants/${detachTarget.value.slug}`, {
|
|
method: 'PATCH',
|
|
body: { partnerId: null },
|
|
})
|
|
detachTarget.value = null
|
|
await Promise.all([refreshPartner(), refreshCustomers()])
|
|
} catch (err: unknown) {
|
|
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
|
detachError.value = e.data?.data?.message ?? e.data?.message ?? String(err)
|
|
} finally {
|
|
detachBusy.value = false
|
|
}
|
|
}
|
|
|
|
// ── Soft-terminate partner ────────────────────────────────────────────────
|
|
const terminateOpen = ref(false)
|
|
const terminateBusy = ref(false)
|
|
const terminateError = ref<string | null>(null)
|
|
|
|
async function confirmTerminate() {
|
|
terminateBusy.value = true
|
|
terminateError.value = null
|
|
try {
|
|
await $fetch(`/api/partners/${slug.value}`, { method: 'DELETE' })
|
|
await navigateTo('/partners')
|
|
} catch (err: unknown) {
|
|
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
|
terminateError.value = e.data?.data?.message ?? e.data?.message ?? String(err)
|
|
terminateBusy.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="partner">
|
|
<PageHeader
|
|
:eyebrow="`Partner · ${partner.slug}`"
|
|
:title="partner.name"
|
|
:subtitle="`${partner.customers} customer(s) · ${partner.marginPct}% revenue share · partner since ${partner.partnershipStartedAt ? new Date(partner.partnershipStartedAt).toISOString().slice(0, 10) : new Date(partner.createdAt).toISOString().slice(0, 10)}`"
|
|
>
|
|
<template #actions>
|
|
<Badge :tone="STATUS_TONE[partner.status]" dot>{{ partner.status }}</Badge>
|
|
<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>
|
|
|
|
<div class="stage">
|
|
<div class="grid">
|
|
<Card>
|
|
<h2>Contract</h2>
|
|
<!-- 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>
|
|
<div class="dl-row"><dt>Customers</dt><dd>
|
|
<span class="num">{{ partner.customers }}</span>
|
|
<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>
|
|
<!-- 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>
|
|
<div class="dl-row"><dt>Company</dt><dd>{{ partner.billingInfo.companyName || '—' }}</dd></div>
|
|
<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>
|
|
<h2>Customers</h2>
|
|
<p class="hint">Tenants whose <code>partnerId</code> points at this partner.</p>
|
|
</div>
|
|
<UiButton variant="primary" :disabled="attachable.length === 0" @click="openAttach">
|
|
<template #leading><UiIcon name="plus" :size="13" /></template>
|
|
Attach tenant
|
|
</UiButton>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Tenant</th><th>Status</th><th>Plan</th><th>Domains</th><th class="th-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="(customers ?? []).length === 0" class="empty">
|
|
<td colspan="5">
|
|
<span class="empty-inner">No customers under this partner yet.</span>
|
|
</td>
|
|
</tr>
|
|
<tr v-for="t in (customers ?? [])" :key="t._id">
|
|
<td>
|
|
<NuxtLink class="link" :to="`/tenants/${t.slug}`">
|
|
<div class="cell-name">{{ t.name }}</div>
|
|
<Mono dim>{{ t.slug }}</Mono>
|
|
</NuxtLink>
|
|
</td>
|
|
<td><Badge :tone="TENANT_STATUS_TONE[t.status]" dot>{{ t.status }}</Badge></td>
|
|
<td><Badge tone="neutral">{{ t.plan }}</Badge></td>
|
|
<td><Mono :dim="!t.domains.length">{{ t.domains[0] || '—' }}</Mono></td>
|
|
<td class="td-right">
|
|
<UiButton variant="danger" size="sm" @click="detachTarget = t; detachError = null">Detach</UiButton>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
|
|
<Card>
|
|
<h2 class="danger">Soft-terminate partner</h2>
|
|
<p>
|
|
Marks the partner as <Mono>terminated</Mono>. Customer tenants keep
|
|
their <Mono>partnerId</Mono> reference — we never hard-delete since
|
|
historical billing depends on it. To re-activate, edit the status
|
|
back to active via the admin API (UI editor lands later).
|
|
</p>
|
|
<UiButton
|
|
variant="danger"
|
|
:disabled="partner.status === 'terminated'"
|
|
@click="terminateOpen = true; terminateError = null"
|
|
>Terminate partner</UiButton>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Attach tenant modal -->
|
|
<ConfirmDialog
|
|
:open="attachOpen"
|
|
:eyebrow="`Partner · ${partner.slug}`"
|
|
title="Attach a tenant"
|
|
confirm-label="Attach"
|
|
:busy="attachBusy"
|
|
@close="attachOpen = false"
|
|
@confirm="submitAttach"
|
|
>
|
|
<p>Pick an unattached tenant. Direct customers (those without a partner) show below.</p>
|
|
<select v-if="attachable.length" v-model="selectedSlug" class="select">
|
|
<option v-for="t in attachable" :key="t._id" :value="t.slug">
|
|
{{ t.name }} — {{ t.slug }} · {{ t.plan }}
|
|
</option>
|
|
</select>
|
|
<p v-else class="hint">No unattached tenants. Detach one from its current partner first.</p>
|
|
<p v-if="attachError" class="err">{{ attachError }}</p>
|
|
</ConfirmDialog>
|
|
|
|
<!-- Detach confirm -->
|
|
<ConfirmDialog
|
|
:open="detachTarget !== null"
|
|
:eyebrow="`Partner · ${partner.slug}`"
|
|
title="Detach this tenant?"
|
|
confirm-label="Detach"
|
|
tone="danger"
|
|
:busy="detachBusy"
|
|
@close="detachTarget = null"
|
|
@confirm="confirmDetach"
|
|
>
|
|
<p>
|
|
Remove <strong>{{ detachTarget?.name }}</strong> from <strong>{{ partner.name }}</strong>.
|
|
The tenant becomes a direct customer (no partner). Reversible — re-attach any time.
|
|
</p>
|
|
<p v-if="detachError" class="err">{{ detachError }}</p>
|
|
</ConfirmDialog>
|
|
|
|
<!-- Terminate partner confirm -->
|
|
<ConfirmDialog
|
|
:open="terminateOpen"
|
|
:eyebrow="`Partner · ${partner.slug}`"
|
|
title="Terminate this partner?"
|
|
confirm-label="Terminate"
|
|
tone="danger"
|
|
:busy="terminateBusy"
|
|
@close="terminateOpen = false"
|
|
@confirm="confirmTerminate"
|
|
>
|
|
<p>
|
|
Set <strong>{{ partner.name }}</strong> status to <Mono>terminated</Mono>. Existing
|
|
customer tenants keep their reference. To re-activate later, set status back via the
|
|
admin API.
|
|
</p>
|
|
<p v-if="terminateError" class="err">{{ terminateError }}</p>
|
|
</ConfirmDialog>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.stage {
|
|
padding: 24px 40px 64px 40px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
|
gap: 16px;
|
|
}
|
|
|
|
h2 {
|
|
font-family: var(--font-display);
|
|
font-weight: 600;
|
|
font-size: 16px;
|
|
letter-spacing: -0.01em;
|
|
margin: 0 0 16px 0;
|
|
}
|
|
h2.danger { color: var(--bad); }
|
|
|
|
p {
|
|
margin: 0 0 14px 0;
|
|
color: var(--text-dim);
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
}
|
|
p.hint { font-size: 12px; color: var(--text-mute); margin: 4px 0 0 0; }
|
|
p.err { color: var(--bad); font-family: var(--font-mono); font-size: 12px; margin-top: 12px; }
|
|
|
|
code {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
background: rgba(244, 243, 238, 0.06);
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
dl { display: flex; flex-direction: column; gap: 10px; }
|
|
.dl-row {
|
|
display: grid;
|
|
grid-template-columns: 120px 1fr;
|
|
gap: 12px;
|
|
font-size: 13px;
|
|
}
|
|
dt {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--text-mute);
|
|
align-self: center;
|
|
}
|
|
dd { margin: 0; color: var(--text); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
|
|
.num {
|
|
font-family: var(--font-display);
|
|
font-weight: 600;
|
|
font-size: 18px;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
|
|
.card-head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
.card-head.padded { padding: 20px 24px 12px 24px; }
|
|
.card-head h2 { margin: 0; }
|
|
|
|
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; }
|
|
td { padding: 14px 16px; color: var(--text); }
|
|
td.td-right { text-align: right; }
|
|
|
|
.empty td { padding: 36px 16px; text-align: center; }
|
|
.empty-inner { color: var(--text-mute); font-size: 13px; }
|
|
|
|
.link {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
text-decoration: none;
|
|
color: var(--text);
|
|
}
|
|
.link:hover .cell-name { text-decoration: underline; }
|
|
.cell-name { font-weight: 500; }
|
|
|
|
.select {
|
|
width: 100%;
|
|
height: 36px;
|
|
padding: 0 10px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
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>
|