v1.3 - client and admin scaffolding
This commit is contained in:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user