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
130 lines
3.9 KiB
Vue
130 lines
3.9 KiB
Vue
<script setup lang="ts">
|
|
// Presence selector for the dashboard greeting strip. Drop-down trigger styled
|
|
// as a pill with a status dot, label, and short hint. Visual only — no API call.
|
|
|
|
type Presence = 'available' | 'meeting' | 'focus' | 'away'
|
|
|
|
interface PresenceOption {
|
|
value: Presence
|
|
label: string
|
|
hint: string
|
|
color: string
|
|
}
|
|
|
|
const opts: PresenceOption[] = [
|
|
{ value: 'available', label: 'Available', hint: 'visible to teammates', color: 'var(--ok)' },
|
|
{ value: 'meeting', label: 'In a meeting', hint: 'silenced · auto-clears', color: 'var(--warn)' },
|
|
{ value: 'focus', label: 'Focus', hint: 'no notifications', color: 'var(--info)' },
|
|
{ value: 'away', label: 'Away', hint: 'be right back', color: 'var(--text-mute)' },
|
|
]
|
|
|
|
const props = defineProps<{ modelValue: Presence }>()
|
|
const emit = defineEmits<{ 'update:modelValue': [Presence] }>()
|
|
|
|
const open = ref(false)
|
|
const rootRef = ref<HTMLElement | null>(null)
|
|
|
|
const current = computed(() => opts.find((o) => o.value === props.modelValue) ?? opts[0])
|
|
|
|
function pick(v: Presence) {
|
|
emit('update:modelValue', v)
|
|
open.value = false
|
|
}
|
|
|
|
function onDocClick(e: MouseEvent) {
|
|
if (!rootRef.value) return
|
|
if (!rootRef.value.contains(e.target as Node)) open.value = false
|
|
}
|
|
|
|
onMounted(() => document.addEventListener('mousedown', onDocClick))
|
|
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick))
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="rootRef" class="presence">
|
|
<button class="trigger" @click="open = !open" :aria-expanded="open">
|
|
<StatusDot :color="current.color" :size="8" />
|
|
<div class="trigger-text">
|
|
<span class="trigger-label">{{ current.label }}</span>
|
|
<span class="trigger-hint">{{ current.hint }}</span>
|
|
</div>
|
|
<UiIcon name="chevDown" :size="12" />
|
|
</button>
|
|
<Transition name="pop">
|
|
<div v-if="open" class="menu">
|
|
<button
|
|
v-for="o in opts"
|
|
:key="o.value"
|
|
class="opt"
|
|
:class="{ active: o.value === modelValue }"
|
|
@click="pick(o.value)"
|
|
>
|
|
<StatusDot :color="o.color" :size="8" />
|
|
<div class="opt-text">
|
|
<span class="opt-label">{{ o.label }}</span>
|
|
<span class="opt-hint">{{ o.hint }}</span>
|
|
</div>
|
|
<UiIcon v-if="o.value === modelValue" name="check" :size="13" />
|
|
</button>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.presence { position: relative; }
|
|
|
|
.trigger {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
color: var(--text);
|
|
}
|
|
.trigger:hover { border-color: var(--border-hi); }
|
|
|
|
.trigger-text { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
|
|
.trigger-label { font-size: 13px; font-weight: 500; }
|
|
.trigger-hint { font-family: var(--font-mono); font-size: 10px; color: var(--text-mute); }
|
|
|
|
.menu {
|
|
position: absolute;
|
|
top: calc(100% + 4px);
|
|
right: 0;
|
|
min-width: 220px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
|
padding: 4px;
|
|
z-index: 50;
|
|
}
|
|
|
|
.opt {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
width: 100%;
|
|
padding: 9px 10px;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
color: var(--text);
|
|
text-align: left;
|
|
}
|
|
.opt:hover, .opt.active { background: var(--row-hover); }
|
|
.opt-text { display: flex; flex-direction: column; flex: 1; gap: 1px; }
|
|
.opt-label { font-size: 13px; font-weight: 500; }
|
|
.opt-hint { font-family: var(--font-mono); font-size: 10px; color: var(--text-mute); }
|
|
|
|
.pop-enter-active, .pop-leave-active { transition: opacity 0.12s, transform 0.12s; }
|
|
.pop-enter-from, .pop-leave-to { opacity: 0; transform: translateY(-4px); }
|
|
</style>
|