b7cddcc6d7
inline-grid with minmax(96px, auto) gave the 4-option control a fixed ~390px intrinsic width; when the contract card's right column was narrower, 'terminated' spilled out. Switch to a full-width grid with minmax(0, 1fr) so columns share space equally, and let button labels ellipsize when the cell shrinks below their preferred width.
685 lines
23 KiB
Vue
685 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: grid;
|
|
width: 100%;
|
|
gap: 2px;
|
|
padding: 2px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 7px;
|
|
box-sizing: border-box;
|
|
}
|
|
.seg.four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
.seg button {
|
|
appearance: none;
|
|
border: 0;
|
|
background: transparent;
|
|
color: var(--text-dim);
|
|
font-family: inherit;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
padding: 6px 8px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
min-width: 0;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.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>
|