diff --git a/apps/operator/pages/tenants/index.vue b/apps/operator/pages/tenants/index.vue index abd17c6..f20a38e 100644 --- a/apps/operator/pages/tenants/index.vue +++ b/apps/operator/pages/tenants/index.vue @@ -36,6 +36,73 @@ const STATUS_TONE: Record = { function navTo(t: Tenant) { return navigateTo(`/tenants/${t.slug}`) } + +// ── Create modal ────────────────────────────────────────────────────────── +// Operator-created tenants are DIRECT customers (no partnerId — partner-owned +// tenants are created through the partner portal wizard instead). Attach to a +// partner later from the tenant detail page if needed. +const createOpen = ref(false) +const createBusy = ref(false) +const createError = ref(null) +const form = reactive({ + slug: '', + name: '', + plan: 'mvp' as 'mvp' | 'pro' | 'enterprise', + cycle: 'monthly' as 'monthly' | 'quarterly' | 'yearly', + currency: 'DKK' as 'DKK' | 'EUR' | 'USD', + seats: 5, + domain: '', + adminName: '', + adminEmail: '', +}) + +function openCreate() { + Object.assign(form, { + slug: '', + name: '', + plan: 'mvp', + cycle: 'monthly', + currency: 'DKK', + seats: 5, + domain: '', + adminName: '', + adminEmail: '', + }) + createError.value = null + createOpen.value = true +} + +async function submitCreate() { + createBusy.value = true + createError.value = null + try { + const domain = form.domain.trim().toLowerCase() + const created = await $fetch('/api/tenants', { + method: 'POST', + body: { + slug: form.slug.trim(), + name: form.name.trim(), + plan: form.plan, + cycle: form.cycle, + currency: form.currency, + seats: form.seats, + ...(domain ? { domains: [domain] } : {}), + ...(form.adminName.trim() && form.adminEmail.trim() + ? { adminName: form.adminName.trim(), adminEmail: form.adminEmail.trim() } + : {}), + }, + }) + createOpen.value = false + await refresh() + await navigateTo(`/tenants/${created.slug}`) + } catch (err: unknown) { + const e = err as { data?: { data?: { message?: string | string[] }; message?: string } } + const msg = e.data?.data?.message ?? e.data?.message ?? String(err) + createError.value = Array.isArray(msg) ? msg.join(' · ') : msg + } finally { + createBusy.value = false + } +} @@ -90,6 +161,7 @@ function navTo(t: Tenant) {
No tenants match this filter. + Create the first one
@@ -122,6 +194,70 @@ function navTo(t: Tenant) { + + +
+ + +
+ + + + +
+ + + +
+

{{ createError }}

+
@@ -251,4 +387,37 @@ td.td-right { text-align: right; } .prov-skipped { background: var(--text-mute); } .prov-error { background: var(--bad); } .prov-pending { background: var(--warn); } + +.form { display: flex; flex-direction: column; gap: 12px; } +.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } +.form label { display: flex; flex-direction: column; gap: 6px; } +.form label span { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-mute); + font-weight: 500; +} +.form input, +.form select { + height: 34px; + padding: 0 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-family: inherit; + font-size: 13px; + outline: none; +} +.form input:focus, +.form select:focus { border-color: var(--accent); } + +.err { + margin: 12px 0 0 0; + color: var(--bad); + font-family: var(--font-mono); + font-size: 12px; +} diff --git a/apps/operator/server/api/tenants/index.post.ts b/apps/operator/server/api/tenants/index.post.ts new file mode 100644 index 0000000..5099f2e --- /dev/null +++ b/apps/operator/server/api/tenants/index.post.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + return platformApi(event, '/tenants', { method: 'POST', body }) +})