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
574 lines
20 KiB
Vue
574 lines
20 KiB
Vue
<script setup lang="ts">
|
|
// Partner dashboard. Landing page for partner-admin role. Mirrors
|
|
// partner-screens.jsx PartnerDashboard (lines 196-365) line-for-line:
|
|
// MRR-card-plus-3-stat strip, customer-health 4-col grid, attention list,
|
|
// recent activity table.
|
|
|
|
|
|
|
|
import { customers, partnerMrrSparkline, partner as fixturePartner } from '~/data/customers'
|
|
import type { CustomerOrg } from '~/data/customers'
|
|
|
|
const toast = useToast()
|
|
const router = useRouter()
|
|
const partnerMode = usePartnerMode()
|
|
|
|
// Real partner identity from /api/me; falls back to the fixture if the user
|
|
// somehow lands here without partner data (middleware should've redirected
|
|
// them, but defending the read keeps the page from crashing).
|
|
const { partner: realPartner } = useMe()
|
|
const partner = computed(() => realPartner.value ?? fixturePartner)
|
|
|
|
const wizardOpen = ref(false)
|
|
const entryCustomer = ref<CustomerOrg | null>(null)
|
|
|
|
// Real MRR from platform-api. Subscriptions are grouped by currency so a
|
|
// partner with mixed-currency customers (e.g. some DKK, some EUR) sees a
|
|
// per-currency total rather than an FX-fudged single number.
|
|
type Currency = 'DKK' | 'EUR' | 'USD'
|
|
interface MrrResponse {
|
|
totals: Array<{ currency: Currency; monthlyMinor: number }>
|
|
breakdown: Array<{
|
|
tenantId: string
|
|
tenantName: string
|
|
currency: Currency
|
|
monthlyMinor: number
|
|
custom: boolean
|
|
}>
|
|
}
|
|
const { data: mrr } = await useFetch<MrrResponse>('/api/partner/mrr', {
|
|
key: 'partner-mrr',
|
|
default: () => ({ totals: [], breakdown: [] }),
|
|
})
|
|
|
|
const totalsDisplay = computed(() =>
|
|
(mrr.value?.totals ?? []).map((t) => ({
|
|
currency: t.currency,
|
|
majorAmount: Math.round(t.monthlyMinor / 100),
|
|
})),
|
|
)
|
|
const hasCustomPriced = computed(() => (mrr.value?.breakdown ?? []).some((b) => b.custom))
|
|
|
|
// Compact one-line summary used in the page subtitle.
|
|
const totalsLine = computed(() => {
|
|
const parts = totalsDisplay.value.map(
|
|
(t) => `${t.majorAmount.toLocaleString('da-DK')} ${t.currency}`,
|
|
)
|
|
if (parts.length === 0) return '0 DKK / mo'
|
|
return parts.join(' + ') + ' / mo'
|
|
})
|
|
|
|
// Real customer count from the breakdown.
|
|
const totalCustomers = computed(() => (mrr.value?.breakdown ?? []).length)
|
|
|
|
// Real end-user count = sum of active User docs across this partner's
|
|
// tenants. Reuses the cached /api/partner/tenants response (same key as
|
|
// the customers page) so this dashboard doesn't issue a second fetch.
|
|
interface PartnerTenant {
|
|
_id: string
|
|
slug: string
|
|
name: string
|
|
status: 'active' | 'pending' | 'suspended' | 'deleted'
|
|
plan?: 'mvp' | 'pro' | 'enterprise'
|
|
seats?: number
|
|
userCount?: number
|
|
newUserCount30d?: number // active users created in the last 30 days
|
|
createdAt?: string // tenant creation timestamp, for the customers delta
|
|
provisioningStatus?: {
|
|
authentik?: 'pending' | 'ok' | 'error' | 'skipped'
|
|
stalwart?: 'pending' | 'ok' | 'error' | 'skipped'
|
|
ocis?: 'pending' | 'ok' | 'error' | 'skipped'
|
|
}
|
|
}
|
|
const { data: tenants } = await useFetch<PartnerTenant[]>('/api/partner/tenants', {
|
|
key: 'partner-tenants',
|
|
default: () => [],
|
|
})
|
|
const totalUsers = computed(() =>
|
|
(tenants.value ?? []).reduce((s, t) => s + (t.userCount ?? 0), 0),
|
|
)
|
|
|
|
// 30-day deltas. Customers delta is derived from tenant.createdAt (already
|
|
// on the doc); end-user delta uses the aggregated newUserCount30d. Both
|
|
// render as "+N / 30d" — or hide when 0 to keep the card clean on a quiet
|
|
// month.
|
|
const SINCE_30D = Date.now() - 30 * 24 * 60 * 60 * 1000
|
|
const newCustomers30d = computed(
|
|
() => (tenants.value ?? []).filter((t) => t.createdAt && new Date(t.createdAt).getTime() >= SINCE_30D).length,
|
|
)
|
|
const newUsers30d = computed(
|
|
() => (tenants.value ?? []).reduce((s, t) => s + (t.newUserCount30d ?? 0), 0),
|
|
)
|
|
const customersDelta = computed(() => (newCustomers30d.value > 0 ? `+${newCustomers30d.value} / 30d` : ''))
|
|
const usersDelta = computed(() => (newUsers30d.value > 0 ? `+${newUsers30d.value} / 30d` : ''))
|
|
|
|
// Sparkline is still fixture-driven — historical MRR isn't stored yet, so
|
|
// the chart shape is decorative. Keep it for the design until we wire a
|
|
// daily MRR snapshot job.
|
|
const sparkline = partnerMrrSparkline
|
|
const sparkLast = sparkline[sparkline.length - 1]
|
|
const sparkTrendPct = '18.2' // matches source label
|
|
|
|
// Attention list · partner-screens.jsx line 207-212
|
|
const alerts = [
|
|
{ id: 'a-bygherre', tone: 'bad' as const, cust: 'Bygherre Cloud', msg: 'Invoice 21 days past due · 2.940 DKK', action: 'Review', custId: 'c-bygherre' },
|
|
{ id: 'a-henriksen', tone: 'warn' as const, cust: 'Henriksen Revision', msg: 'SPF record missing on h-revision.dk', action: 'Fix DNS', custId: 'c-henriksen' },
|
|
{ id: 'a-aalborg', tone: 'warn' as const, cust: 'Aalborg Logistik', msg: 'Approaching seat limit · 87/100 used', action: 'Upsell', custId: 'c-aalborg' },
|
|
{ id: 'a-norrebro', tone: 'info' as const, cust: 'Nørrebro Studio', msg: 'Trial ends in 7 days', action: 'Follow up', custId: 'c-norrebro' },
|
|
]
|
|
|
|
// Recent activity · partner-screens.jsx line 332-336
|
|
const activity = [
|
|
{ when: '14:02', cust: 'Acme Workspace', who: 'Anne Baslund', action: 'invited 3 users', tone: 'info' as const },
|
|
{ when: '12:18', cust: 'Bygherre Cloud', who: 'system', action: 'invoice marked past-due', tone: 'bad' as const },
|
|
{ when: '11:44', cust: 'Aalborg Logistik', who: 'Sofie Lindberg', action: 'upgraded to Enterprise', tone: 'ok' as const },
|
|
{ when: '10:08', cust: 'Nørrebro Studio', who: 'NordicMSP', action: 'created new customer org', tone: 'info' as const },
|
|
{ when: '09:34', cust: 'Henriksen Revision', who: 'system', action: 'DNS health alert · SPF', tone: 'warn' as const },
|
|
]
|
|
|
|
function statusBadge(s: string): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
|
|
switch (s) {
|
|
case 'healthy': return { tone: 'ok', label: 'healthy' }
|
|
case 'attention': return { tone: 'warn', label: 'attention' }
|
|
case 'past_due': return { tone: 'bad', label: 'past-due' }
|
|
case 'trial': return { tone: 'info', label: 'trial' }
|
|
default: return { tone: 'neutral', label: s }
|
|
}
|
|
}
|
|
|
|
function startEnter(c: CustomerOrg) {
|
|
entryCustomer.value = c
|
|
}
|
|
function confirmEnter(reason: string) {
|
|
if (!entryCustomer.value) return
|
|
const c = entryCustomer.value
|
|
partnerMode.enter(c.id)
|
|
entryCustomer.value = null
|
|
toast.info(`Entered ${c.name}`, reason ? `Reason: ${reason}` : 'No reason captured')
|
|
router.push('/admin')
|
|
}
|
|
|
|
function onAlert(a: typeof alerts[number]) {
|
|
toast.ok(`${a.action}: ${a.cust}`, 'Workflow stub fired')
|
|
}
|
|
|
|
function activitySwatch(name: string) {
|
|
return customers.find((c) => c.name === name)?.brandColor || 'var(--text-mute)'
|
|
}
|
|
|
|
// ── Real health + activity (replace fixture cards) ───────────────────────
|
|
|
|
// Health badge derived from real tenant state. We have:
|
|
// - Tenant.status: active / pending / suspended / deleted
|
|
// - provisioningStatus per integration: ok / pending / error / skipped
|
|
// Map: any provisioning error or suspended/deleted → bad. Pending or
|
|
// awaiting provisioning → warn. Active + all integrations ok|skipped → ok.
|
|
function tenantHealth(t: PartnerTenant): 'ok' | 'warn' | 'bad' {
|
|
if (t.status === 'suspended' || t.status === 'deleted') return 'bad'
|
|
const states = Object.values(t.provisioningStatus ?? {}) as Array<string | undefined>
|
|
if (states.some((s) => s === 'error')) return 'bad'
|
|
if (t.status === 'pending' || states.some((s) => s === 'pending')) return 'warn'
|
|
return 'ok'
|
|
}
|
|
|
|
const PLAN_LABEL: Record<'mvp' | 'pro' | 'enterprise', string> = {
|
|
mvp: 'Starter',
|
|
pro: 'Business',
|
|
enterprise: 'Enterprise',
|
|
}
|
|
|
|
const healthTiles = computed(() =>
|
|
(tenants.value ?? []).map((t) => ({
|
|
id: t._id,
|
|
slug: t.slug,
|
|
name: t.name,
|
|
planLabel: PLAN_LABEL[t.plan ?? 'pro'],
|
|
usedSeats: t.userCount ?? 0,
|
|
totalSeats: t.seats ?? 0,
|
|
tone: tenantHealth(t),
|
|
})),
|
|
)
|
|
const healthyCount = computed(() => healthTiles.value.filter((t) => t.tone === 'ok').length)
|
|
|
|
// Real audit feed. Each event has resourceName + actor.email + at +
|
|
// outcome ('success'|'failure'|'pending'). We render the verb in
|
|
// dotted form (e.g. tenant.created) since the dashboard is a glance
|
|
// view — clickthrough to /partner/audit can show the full row context.
|
|
interface ActivityEvent {
|
|
_id: string
|
|
at: string
|
|
action: string
|
|
resourceName?: string
|
|
tenantSlug?: string
|
|
outcome?: 'success' | 'failure' | 'pending'
|
|
actor?: { email?: string; userId?: string }
|
|
}
|
|
const { data: activityRaw } = await useFetch<ActivityEvent[]>('/api/partner/activity', {
|
|
key: 'partner-activity',
|
|
query: { limit: 8 },
|
|
default: () => [],
|
|
})
|
|
|
|
function eventTone(e: ActivityEvent): 'ok' | 'warn' | 'bad' | 'info' {
|
|
if (e.outcome === 'failure') return 'bad'
|
|
if (e.outcome === 'pending') return 'warn'
|
|
// Heuristic: actions ending in .deleted / .suspended / .terminated read as bad
|
|
if (/\.(deleted|suspended|terminated|removed)$/.test(e.action)) return 'bad'
|
|
return 'info'
|
|
}
|
|
|
|
function fmtTime(iso: string): string {
|
|
const d = new Date(iso)
|
|
return d.toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
function tenantNameFromSlug(slug?: string): string {
|
|
if (!slug) return '—'
|
|
return tenants.value?.find((t) => t.slug === slug)?.name ?? slug
|
|
}
|
|
|
|
const realActivity = computed(() =>
|
|
(activityRaw.value ?? []).map((e) => ({
|
|
id: e._id,
|
|
when: fmtTime(e.at),
|
|
cust: tenantNameFromSlug(e.tenantSlug),
|
|
who: e.actor?.email ?? 'system',
|
|
action: e.action,
|
|
tone: eventTone(e),
|
|
})),
|
|
)
|
|
|
|
function provisioned() {
|
|
toast.ok('Customer provisioned', 'Welcome email is on its way to the first admin')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<PageHeader
|
|
:eyebrow="`${partner.name} · Partner console`"
|
|
title="Portfolio overview"
|
|
:subtitle="`${totalCustomers} customer organizations · ${totalUsers} end users · ${totalsLine} MRR${hasCustomPriced ? ' (+ custom-priced)' : ''}`"
|
|
>
|
|
<template #actions>
|
|
<UiButton variant="secondary" @click="toast.ok('Exporting', 'Portfolio PDF · sent to your inbox')">
|
|
<template #leading><UiIcon name="download" :size="14" /></template>
|
|
Export report
|
|
</UiButton>
|
|
<UiButton variant="primary" @click="wizardOpen = true">
|
|
<template #leading><UiIcon name="plus" :size="14" /></template>
|
|
New customer
|
|
</UiButton>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<div class="content">
|
|
<!-- Top strip: MRR card (1.4fr) + 3 stat cards -->
|
|
<div class="top-strip">
|
|
<Card :pad="0" class="mrr-card">
|
|
<div class="mrr-head">
|
|
<Eyebrow>Current MRR</Eyebrow>
|
|
<div class="mrr-totals">
|
|
<template v-if="totalsDisplay.length === 0">
|
|
<div class="mrr-value">0 <span class="dkk">DKK / mo</span></div>
|
|
</template>
|
|
<template v-else>
|
|
<div v-for="t in totalsDisplay" :key="t.currency" class="mrr-line">
|
|
<span class="mrr-amount">{{ t.majorAmount.toLocaleString('da-DK') }}</span>
|
|
<span class="mrr-cur">{{ t.currency }} / mo</span>
|
|
</div>
|
|
</template>
|
|
<Mono v-if="hasCustomPriced" dim>+ custom-priced</Mono>
|
|
</div>
|
|
</div>
|
|
<div class="mrr-chart">
|
|
<!-- Sparkline values are placeholder until a daily MRR-snapshot
|
|
job exists. Multi-currency makes a single sparkline less
|
|
meaningful anyway; the chart is decorative for now. -->
|
|
<PartnerSparkline :values="sparkline" :width="420" :height="64" stroke="var(--text)" fill="var(--row-hover)" />
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<Stat label="Customers" :value="totalCustomers" :delta="customersDelta" delta-tone="up" />
|
|
</Card>
|
|
<Card>
|
|
<Stat label="End users" :value="totalUsers" :delta="usersDelta" delta-tone="up" />
|
|
</Card>
|
|
<Card>
|
|
<Stat label="Issues" :value="alerts.length" hint="1 critical · 2 warning" />
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Health grid + Attention -->
|
|
<div class="grid-2">
|
|
<Card :pad="0">
|
|
<div class="card-head">
|
|
<div>
|
|
<Eyebrow>Health</Eyebrow>
|
|
<div class="card-title">Customer status</div>
|
|
</div>
|
|
<Mono dim>{{ healthyCount }} healthy of {{ totalCustomers }}</Mono>
|
|
</div>
|
|
<div v-if="healthTiles.length === 0" class="empty-state">
|
|
<Mono dim>// no customers yet — provision your first from the New customer button</Mono>
|
|
</div>
|
|
<div v-else class="health-grid">
|
|
<NuxtLink
|
|
v-for="t in healthTiles"
|
|
:key="t.id"
|
|
:to="`/partner/customers`"
|
|
class="health-tile"
|
|
>
|
|
<div class="tile-head">
|
|
<span class="tile-name">{{ t.name }}</span>
|
|
<StatusDot :color="`var(--${t.tone})`" :size="6" :glow="false" />
|
|
</div>
|
|
<div class="tile-meta">{{ t.planLabel }} · {{ t.usedSeats }}/{{ t.totalSeats }}</div>
|
|
<Mono dim class="tile-slug">{{ t.slug }}</Mono>
|
|
</NuxtLink>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card :pad="0">
|
|
<div class="card-head">
|
|
<div>
|
|
<Eyebrow>Attention</Eyebrow>
|
|
<div class="card-title">What needs your attention</div>
|
|
</div>
|
|
</div>
|
|
<div class="attn-list">
|
|
<div
|
|
v-for="a in alerts"
|
|
:key="a.id"
|
|
class="attn-row"
|
|
:style="{ borderLeftColor: `var(--${a.tone})` }"
|
|
>
|
|
<div class="attn-meta">
|
|
<div class="attn-top">
|
|
<span class="attn-cust">{{ a.cust }}</span>
|
|
<Mono dim>{{ a.tone }}</Mono>
|
|
</div>
|
|
<div class="attn-msg">{{ a.msg }}</div>
|
|
</div>
|
|
<UiButton size="sm" variant="secondary" @click="onAlert(a)">{{ a.action }}</UiButton>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Recent activity -->
|
|
<Card :pad="0">
|
|
<div class="card-head">
|
|
<div>
|
|
<Eyebrow>Activity</Eyebrow>
|
|
<div class="card-title">Recent across portfolio</div>
|
|
</div>
|
|
<UiButton size="sm" variant="ghost" @click="router.push('/partner/audit')">
|
|
View all
|
|
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
|
</UiButton>
|
|
</div>
|
|
<div v-if="realActivity.length === 0" class="empty-state">
|
|
<Mono dim>// no recent events yet</Mono>
|
|
</div>
|
|
<div v-else class="activity-list">
|
|
<div
|
|
v-for="(a, i) in realActivity"
|
|
:key="a.id"
|
|
class="activity-row"
|
|
:class="{ last: i === realActivity.length - 1 }"
|
|
>
|
|
<Mono>{{ a.when }}</Mono>
|
|
<div class="activity-cust">
|
|
<span class="activity-cust-name">{{ a.cust }}</span>
|
|
</div>
|
|
<div class="activity-text">
|
|
<span class="activity-who">{{ a.who }}</span> <Mono dim>{{ a.action }}</Mono>
|
|
</div>
|
|
<div class="activity-tone">
|
|
<Badge :tone="a.tone" dot>{{ a.tone }}</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<PartnerCustomerCreateWizard
|
|
:open="wizardOpen"
|
|
@close="wizardOpen = false"
|
|
@done="provisioned"
|
|
/>
|
|
<PartnerEnterCustomerConfirmModal
|
|
:customer="entryCustomer"
|
|
@close="entryCustomer = null"
|
|
@confirm="confirmEnter"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.content { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
|
|
|
|
/* Top strip: MRR card (1.4fr) + 3 stat cards */
|
|
.top-strip {
|
|
display: grid;
|
|
grid-template-columns: 1.4fr 1fr 1fr 1fr;
|
|
gap: 12px;
|
|
}
|
|
|
|
.mrr-card { overflow: hidden; display: flex; flex-direction: column; }
|
|
.mrr-head { padding: 20px 24px 12px; }
|
|
.mrr-value-row {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 10px;
|
|
margin-top: 8px;
|
|
}
|
|
.mrr-totals {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
margin-top: 8px;
|
|
}
|
|
.mrr-line {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 8px;
|
|
}
|
|
.mrr-amount {
|
|
font-family: var(--font-display);
|
|
font-weight: 600;
|
|
font-size: 28px;
|
|
letter-spacing: -0.025em;
|
|
line-height: 1.05;
|
|
}
|
|
.mrr-cur { font-size: 14px; color: var(--text-mute); font-weight: 500; }
|
|
.mrr-value {
|
|
font-family: var(--font-display);
|
|
font-weight: 600;
|
|
font-size: 32px;
|
|
letter-spacing: -0.025em;
|
|
line-height: 1;
|
|
}
|
|
.dkk { font-size: 18px; color: var(--text-mute); font-weight: 500; }
|
|
.trend {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
color: var(--ok);
|
|
font-weight: 500;
|
|
}
|
|
.mrr-chart { padding: 0 12px 12px; }
|
|
.mrr-chart :deep(svg) { width: 100%; height: 64px; }
|
|
|
|
/* 2-up grid */
|
|
.grid-2 {
|
|
display: grid;
|
|
grid-template-columns: 1.4fr 1fr;
|
|
gap: 16px;
|
|
}
|
|
|
|
.card-head {
|
|
padding: 20px 24px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.card-title {
|
|
font-family: var(--font-display);
|
|
font-weight: 600;
|
|
font-size: 18px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* Health grid: 4 columns of tiles */
|
|
.health-grid {
|
|
padding: 12px;
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 8px;
|
|
}
|
|
|
|
.health-tile {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 12px;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
color: var(--text);
|
|
font: inherit;
|
|
transition: border-color 120ms;
|
|
}
|
|
.health-tile:hover { border-color: var(--text); }
|
|
.health-tile { text-decoration: none; display: block; }
|
|
.tile-slug { display: block; margin-top: 6px; font-size: 10px; }
|
|
|
|
.empty-state {
|
|
padding: 32px 24px;
|
|
text-align: center;
|
|
color: var(--text-mute);
|
|
}
|
|
.tile-head { display: flex; align-items: center; gap: 8px; }
|
|
.tile-swatch { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; }
|
|
.tile-name {
|
|
flex: 1;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.tile-meta {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
color: var(--text-mute);
|
|
margin-top: 8px;
|
|
}
|
|
.tile-mrr {
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
margin-top: 6px;
|
|
}
|
|
|
|
/* Attention list */
|
|
.attn-list { padding: 8px 8px 12px; display: flex; flex-direction: column; gap: 4px; }
|
|
.attn-row {
|
|
padding: 10px 14px;
|
|
border-radius: 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
border-left: 2px solid var(--border);
|
|
}
|
|
.attn-meta { flex: 1; min-width: 0; }
|
|
.attn-top { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
|
|
.attn-cust { font-size: 12px; font-weight: 600; }
|
|
.attn-msg { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
|
|
|
|
/* Recent activity */
|
|
.activity-list { display: flex; flex-direction: column; }
|
|
.activity-row {
|
|
display: grid;
|
|
grid-template-columns: 60px 200px 1fr 100px;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px 24px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.activity-row.last { border-bottom: none; }
|
|
.activity-cust { display: flex; align-items: center; gap: 8px; }
|
|
.activity-swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
|
|
.activity-cust-name { font-size: 13px; font-weight: 500; }
|
|
.activity-text { font-size: 13px; color: var(--text-dim); }
|
|
.activity-who { color: var(--text); }
|
|
.activity-tone { text-align: right; }
|
|
|
|
@media (max-width: 1280px) {
|
|
.top-strip { grid-template-columns: 1fr 1fr; }
|
|
.grid-2 { grid-template-columns: 1fr; }
|
|
.health-grid { grid-template-columns: repeat(3, 1fr); }
|
|
}
|
|
</style>
|