v1.3 - client and admin scaffolding

This commit is contained in:
2026-04-25 22:51:36 +12:00
parent bc211ffcc8
commit 8cf9bfb441
54 changed files with 8868 additions and 1234 deletions
@@ -0,0 +1,857 @@
<script lang="ts">
import { api } from '$lib/api';
import type { ClientAccessAccount, ClientAccessFeature, ClientAccessPowerBiExport } from '$lib/types';
let { data } = $props();
let clients = $state<ClientAccessAccount[]>([]);
let exportPreview = $state<ClientAccessPowerBiExport>({
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
clients: []
});
let selectedClientId = $state(0);
let fullName = $state('');
let email = $state('');
let role = $state('viewer');
let status = $state('invited');
let isNewUser = $state(true);
let formError = $state('');
let formSuccess = $state('');
let isSubmitting = $state(false);
let savingUserId = $state<number | null>(null);
let savingFeatureId = $state<number | null>(null);
let previewStatus = $state('Live preview loaded');
function formatDate(value: string | null | undefined) {
if (!value) {
return 'No activity yet';
}
return new Intl.DateTimeFormat('en-NZ', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
}).format(new Date(value));
}
function initials(value: string) {
return value
.split(' ')
.map((piece) => piece[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
function replaceClient(updatedClient: ClientAccessAccount) {
clients = clients.map((client) => (client.id === updatedClient.id ? updatedClient : client));
}
async function refreshExportPreview() {
exportPreview = await api.clientAccessExport();
previewStatus = `Preview refreshed ${formatDate(exportPreview.generated_at)}`;
}
async function handleCreateUser(event: SubmitEvent) {
event.preventDefault();
formError = '';
formSuccess = '';
if (!selectedClientId) {
formError = 'Select a client before creating a user.';
return;
}
if (!fullName.trim() || !email.trim()) {
formError = 'Name and email are required.';
return;
}
isSubmitting = true;
try {
const updatedClient = await api.createClientUser({
client_account_id: selectedClientId,
full_name: fullName.trim(),
email: email.trim(),
role,
status,
is_new_user: isNewUser
});
replaceClient(updatedClient);
await refreshExportPreview();
fullName = '';
email = '';
role = 'viewer';
status = 'invited';
isNewUser = true;
formSuccess = 'User created and included in the export preview.';
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to create client user';
} finally {
isSubmitting = false;
}
}
async function updateUser(userId: number, payload: { role?: string; status?: string; is_new_user?: boolean }) {
savingUserId = userId;
formError = '';
formSuccess = '';
try {
const updatedClient = await api.updateClientUser(userId, payload);
replaceClient(updatedClient);
await refreshExportPreview();
formSuccess = 'User access updated.';
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to update client user';
} finally {
savingUserId = null;
}
}
async function toggleFeature(feature: ClientAccessFeature) {
savingFeatureId = feature.id;
formError = '';
formSuccess = '';
try {
const updatedClient = await api.updateClientFeature(feature.id, { enabled: !feature.enabled });
replaceClient(updatedClient);
await refreshExportPreview();
formSuccess = `${feature.feature_name} ${feature.enabled ? 'disabled' : 'enabled'}.`;
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to update feature access';
} finally {
savingFeatureId = null;
}
}
$effect(() => {
if (!clients.length && data.clients.length) {
clients = structuredClone(data.clients) as ClientAccessAccount[];
}
if (!exportPreview.generated_at && data.exportPreview.generated_at) {
exportPreview = structuredClone(data.exportPreview) as ClientAccessPowerBiExport;
}
if (!selectedClientId && data.clients[0]) {
selectedClientId = data.clients[0].id;
}
});
const selectedClient = $derived(clients.find((client) => client.id === selectedClientId) ?? clients[0]);
const totalUsers = $derived(clients.reduce((sum, client) => sum + client.users.length, 0));
const totalEnabledFeatures = $derived(clients.reduce((sum, client) => sum + client.enabled_feature_count, 0));
const previewJson = $derived(JSON.stringify(exportPreview, null, 2));
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Client Amend Area</p>
<h2>Control new users, existing users, and every feature flag in one operational workspace.</h2>
<p>The preview shows the live Power BI export payload after each amendment so the admin surface and reporting output stay aligned.</p>
</div>
</section>
<section class="metric-row">
<article class="metric-card">
<span>Total Clients</span>
<strong>{clients.length}</strong>
<p>Accounts currently staged in the client app</p>
</article>
<article class="metric-card">
<span>Total Users</span>
<strong>{totalUsers}</strong>
<p>New and existing users across every client</p>
</article>
<article class="metric-card">
<span>Enabled Features</span>
<strong>{totalEnabledFeatures}</strong>
<p>Feature switches currently turned on</p>
</article>
</section>
<section class="workspace-grid">
<article class="surface-card client-list-card">
<div class="card-toolbar">
<div>
<h3>Clients</h3>
<p>Select a client before amending users or feature access.</p>
</div>
</div>
<div class="client-list">
{#each clients as client}
<button
class:selected={client.id === selectedClient?.id}
class="client-row"
type="button"
onclick={() => {
selectedClientId = client.id;
formError = '';
formSuccess = '';
}}
>
<div class="client-row-head">
<span class="client-badge">{client.client_code}</span>
<div>
<strong>{client.name}</strong>
<span>{client.tenant_id}</span>
</div>
</div>
<div class="client-row-meta">
<span class={`status-pill ${client.status === 'active' ? 'positive' : 'neutral'}`}>{client.status}</span>
<small>{client.active_user_count} active users</small>
</div>
</button>
{/each}
</div>
</article>
<article class="surface-card amend-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Selected Client</p>
<h3>{selectedClient?.name ?? 'No client selected'}</h3>
<p>{selectedClient?.powerbi_workspace ?? 'No Power BI workspace assigned yet.'}</p>
</div>
{#if selectedClient}
<span class={`status-pill ${selectedClient.status === 'active' ? 'positive' : 'neutral'}`}>{selectedClient.status}</span>
{/if}
</div>
<div class="client-summary">
<article>
<span>Existing users</span>
<strong>{selectedClient ? selectedClient.users.length - selectedClient.new_user_count : 0}</strong>
</article>
<article>
<span>New users</span>
<strong>{selectedClient?.new_user_count ?? 0}</strong>
</article>
<article>
<span>Enabled features</span>
<strong>{selectedClient?.enabled_feature_count ?? 0}/{selectedClient?.total_feature_count ?? 0}</strong>
</article>
</div>
<form class="create-user-form" onsubmit={handleCreateUser}>
<div class="section-title">
<h4>Add New User</h4>
<span>Creates the user and immediately updates the export preview.</span>
</div>
<div class="form-grid">
<label>
<span>Full name</span>
<input bind:value={fullName} placeholder="Jordan Lee" />
</label>
<label>
<span>Email</span>
<input bind:value={email} type="email" placeholder="jordan.lee@client.example" />
</label>
<label>
<span>Role</span>
<select bind:value={role}>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
<option value="viewer">Viewer</option>
</select>
</label>
<label>
<span>Status</span>
<select bind:value={status}>
<option value="invited">Invited</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
</select>
</label>
</div>
<label class="toggle-row">
<div>
<strong>Mark as new user</strong>
<span>Controls the onboarding signal carried into the export.</span>
</div>
<input bind:checked={isNewUser} type="checkbox" />
</label>
<div class="form-actions">
<button class="primary-button" type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving user...' : 'Create User'}
</button>
{#if formError}
<strong class="message error">{formError}</strong>
{/if}
{#if !formError && formSuccess}
<strong class="message success">{formSuccess}</strong>
{/if}
</div>
</form>
<div class="section-title">
<h4>Existing Users</h4>
<span>Roles, lifecycle state, and new-user status can be amended inline.</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Status</th>
<th>New User</th>
<th>Last Login</th>
</tr>
</thead>
<tbody>
{#each selectedClient?.users ?? [] as user}
<tr>
<td class="user-cell">
<div class="user-item">
<span class="user-badge">{initials(user.full_name)}</span>
<div>
<strong>{user.full_name}</strong>
<span>{user.email}</span>
</div>
</div>
</td>
<td>
<select
value={user.role}
disabled={savingUserId === user.id}
onchange={(event) =>
updateUser(user.id, { role: (event.currentTarget as HTMLSelectElement).value })}
>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
<option value="viewer">Viewer</option>
</select>
</td>
<td>
<select
value={user.status}
disabled={savingUserId === user.id}
onchange={(event) =>
updateUser(user.id, { status: (event.currentTarget as HTMLSelectElement).value })}
>
<option value="active">Active</option>
<option value="invited">Invited</option>
<option value="suspended">Suspended</option>
</select>
</td>
<td>
<label class="inline-toggle">
<input
checked={user.is_new_user}
disabled={savingUserId === user.id}
type="checkbox"
onchange={(event) =>
updateUser(user.id, { is_new_user: (event.currentTarget as HTMLInputElement).checked })}
/>
<span>{user.is_new_user ? 'New' : 'Existing'}</span>
</label>
</td>
<td>
<div class="date-block">
<strong>{user.status}</strong>
<span>{formatDate(user.last_login_at)}</span>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</article>
<article class="surface-card feature-card">
<div class="card-toolbar">
<div>
<h3>Feature Access</h3>
<p>Every client feature can be switched on or off independently.</p>
</div>
</div>
<div class="feature-list">
{#each selectedClient?.features ?? [] as feature}
<article class="feature-row">
<div>
<div class="feature-head">
<strong>{feature.feature_name}</strong>
<span>{feature.feature_group}</span>
</div>
<p>{feature.description}</p>
</div>
<button
class:enabled={feature.enabled}
class="feature-toggle"
type="button"
disabled={savingFeatureId === feature.id}
onclick={() => toggleFeature(feature)}
>
<span>{savingFeatureId === feature.id ? 'Saving...' : feature.enabled ? 'On' : 'Off'}</span>
</button>
</article>
{/each}
</div>
</article>
</section>
<section class="preview-grid">
<article class="surface-card preview-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Power BI Preview</p>
<h3>Export Shape</h3>
<p>{previewStatus}</p>
</div>
<span class="endpoint-pill">GET /api/powerbi/client-access</span>
</div>
<div class="preview-stats">
<article>
<span>Client rows</span>
<strong>{exportPreview.client_rows.length}</strong>
</article>
<article>
<span>User rows</span>
<strong>{exportPreview.user_rows.length}</strong>
</article>
<article>
<span>Feature rows</span>
<strong>{exportPreview.feature_rows.length}</strong>
</article>
</div>
<pre>{previewJson}</pre>
</article>
</section>
<style>
h2,
h3,
h4,
p,
pre {
margin: 0;
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.metric-row,
.workspace-grid,
.preview-grid {
margin-bottom: 1.25rem;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
max-width: 18ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.card-toolbar p,
.client-row span,
.section-title span,
.feature-row p,
.feature-head span,
.date-block span,
.message,
pre {
color: var(--muted);
}
.metric-row,
.workspace-grid,
.preview-stats,
.client-summary,
.form-grid {
display: grid;
gap: 1rem;
}
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.workspace-grid {
grid-template-columns: 0.78fr 1.5fr 1fr;
align-items: start;
}
.preview-stats,
.client-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-card,
.surface-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.35rem;
box-shadow: var(--shadow);
}
.metric-card {
padding: 1.15rem 1.2rem;
}
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.9rem;
}
.metric-card strong {
display: block;
margin: 0.55rem 0 0.3rem;
font-size: 1.9rem;
font-weight: 700;
}
.surface-card {
padding: 1.2rem;
}
.card-toolbar,
.client-row,
.client-row-head,
.client-row-meta,
.section-title,
.feature-row,
.form-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.card-toolbar,
.section-title {
margin-bottom: 1rem;
}
.card-toolbar h3,
.section-title h4 {
font-weight: 700;
}
.client-list,
.feature-list {
display: grid;
gap: 0.75rem;
}
.client-row {
width: 100%;
padding: 0.95rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
text-align: left;
cursor: pointer;
}
.client-row.selected {
border-color: #b9dfc6;
background: var(--green-soft);
}
.client-row strong,
.user-item strong,
.feature-head strong,
.date-block strong {
display: block;
font-size: 0.96rem;
}
.client-badge,
.user-badge {
width: 2.45rem;
height: 2.45rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.8rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.client-summary {
margin-bottom: 1rem;
}
.client-summary article,
.preview-stats article {
padding: 0.9rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.client-summary span,
.preview-stats span {
display: block;
margin-bottom: 0.28rem;
color: var(--muted);
font-size: 0.84rem;
}
.client-summary strong,
.preview-stats strong {
font-size: 1.3rem;
font-weight: 700;
}
.create-user-form {
margin-bottom: 1.2rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
label {
display: grid;
gap: 0.4rem;
}
label span,
.toggle-row span {
font-size: 0.84rem;
color: var(--muted);
}
input,
select {
width: 100%;
padding: 0.82rem 0.88rem;
border: 1px solid var(--line-strong);
border-radius: 0.82rem;
background: #fff;
color: var(--text);
}
.toggle-row,
.inline-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.toggle-row {
margin-top: 1rem;
}
.toggle-row input,
.inline-toggle input {
width: auto;
}
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 0.85rem;
padding: 0.85rem 1rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.2);
font-weight: 600;
cursor: pointer;
}
.primary-button:disabled {
opacity: 0.72;
cursor: wait;
}
.message.error {
color: #b33636;
}
.message.success {
color: var(--green-deep);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0 0.75rem;
}
th,
td {
padding: 1rem;
text-align: left;
white-space: nowrap;
}
th {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
tbody td {
background: var(--panel-soft);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
tbody td:first-child {
border-left: 1px solid var(--line);
border-radius: 1rem 0 0 1rem;
}
tbody td:last-child {
border-right: 1px solid var(--line);
border-radius: 0 1rem 1rem 0;
}
.user-cell {
min-width: 19rem;
}
.user-item,
.feature-head {
display: flex;
align-items: center;
gap: 0.75rem;
}
.status-pill,
.endpoint-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.78rem;
border-radius: 999px;
font-size: 0.84rem;
font-weight: 600;
}
.status-pill.positive {
color: var(--green-deep);
background: var(--green-soft);
}
.status-pill.neutral {
color: #5a6c63;
background: #edf2ef;
}
.feature-row {
padding: 0.95rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.feature-toggle {
min-width: 4.6rem;
padding: 0.72rem 0.8rem;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: #fff;
color: #5a6c63;
font-weight: 700;
cursor: pointer;
}
.feature-toggle.enabled {
color: #fff;
border-color: transparent;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
}
.feature-toggle:disabled {
opacity: 0.7;
cursor: wait;
}
.endpoint-pill {
color: #245961;
background: var(--blue-soft);
}
pre {
margin-top: 1rem;
padding: 1rem;
border-radius: 1rem;
background: #18231d;
border: 1px solid #1f3028;
color: #d6e4dc;
overflow: auto;
font-size: 0.82rem;
line-height: 1.55;
max-height: 34rem;
}
@media (max-width: 1220px) {
.workspace-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 960px) {
.metric-row,
.preview-stats,
.client-summary,
.form-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.card-toolbar,
.client-row,
.feature-row,
.form-actions,
.section-title {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(307, '/admin/client-access');
}