So many things
This commit is contained in:
@@ -0,0 +1,953 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
import type {
|
||||||
|
PermissionsCategorized,
|
||||||
|
PermissionCategory,
|
||||||
|
PermissionNode,
|
||||||
|
} from "$lib/optima-api/modules/permissions";
|
||||||
|
import type { Role } from "$lib/optima-api/modules/roles";
|
||||||
|
|
||||||
|
export let isOpen = false;
|
||||||
|
export let permissionNodes: PermissionsCategorized = {};
|
||||||
|
export let roleToEdit: Role | null = null;
|
||||||
|
export let onClose: () => void = () => {};
|
||||||
|
export let onSuccess: () => void = () => {};
|
||||||
|
|
||||||
|
$: isEditMode = roleToEdit !== null;
|
||||||
|
|
||||||
|
$: if (isOpen && roleToEdit) {
|
||||||
|
title = roleToEdit.title;
|
||||||
|
moniker = roleToEdit.moniker;
|
||||||
|
monikerEdited = true;
|
||||||
|
selectedPermissions = new Set(roleToEdit.permissions);
|
||||||
|
|
||||||
|
// Auto-expand categories that contain selected permissions
|
||||||
|
const sel = new Set(roleToEdit.permissions);
|
||||||
|
const expanded = new Set<string>();
|
||||||
|
for (const [key, cat] of Object.entries(permissionNodes) as [
|
||||||
|
string,
|
||||||
|
PermissionCategory,
|
||||||
|
][]) {
|
||||||
|
if (cat.permissions.some((p) => sel.has(p.node))) {
|
||||||
|
expanded.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expandedCategories = expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = "";
|
||||||
|
let moniker = "";
|
||||||
|
let monikerEdited = false;
|
||||||
|
let selectedPermissions = new Set<string>();
|
||||||
|
let permissionSearch = "";
|
||||||
|
let expandedCategories = new Set<string>();
|
||||||
|
let isSubmitting = false;
|
||||||
|
let submitError = "";
|
||||||
|
|
||||||
|
$: if (!monikerEdited) {
|
||||||
|
moniker = title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/[^a-z0-9-]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
$: filteredEntries = (() => {
|
||||||
|
const entries = Object.entries(permissionNodes) as [
|
||||||
|
string,
|
||||||
|
PermissionCategory,
|
||||||
|
][];
|
||||||
|
if (!permissionSearch.trim()) return entries;
|
||||||
|
const q = permissionSearch.toLowerCase();
|
||||||
|
return entries
|
||||||
|
.map(([key, cat]): [string, PermissionCategory] => [
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
...cat,
|
||||||
|
permissions: cat.permissions.filter(
|
||||||
|
(p: PermissionNode) =>
|
||||||
|
p.node.toLowerCase().includes(q) ||
|
||||||
|
p.description.toLowerCase().includes(q),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.filter(([, cat]) => cat.permissions.length > 0);
|
||||||
|
})();
|
||||||
|
|
||||||
|
$: isValid = title.trim().length > 0 && moniker.trim().length > 0;
|
||||||
|
$: selectedCount = selectedPermissions.size;
|
||||||
|
|
||||||
|
function togglePermission(node: string) {
|
||||||
|
const next = new Set(selectedPermissions);
|
||||||
|
next.has(node) ? next.delete(node) : next.add(node);
|
||||||
|
selectedPermissions = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCategory(key: string) {
|
||||||
|
const next = new Set(expandedCategories);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
expandedCategories = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllInCategory(perms: PermissionNode[]) {
|
||||||
|
const next = new Set(selectedPermissions);
|
||||||
|
const allSelected = perms.every((p) => next.has(p.node));
|
||||||
|
perms.forEach((p) =>
|
||||||
|
allSelected ? next.delete(p.node) : next.add(p.node),
|
||||||
|
);
|
||||||
|
selectedPermissions = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
title = "";
|
||||||
|
moniker = "";
|
||||||
|
monikerEdited = false;
|
||||||
|
selectedPermissions = new Set();
|
||||||
|
permissionSearch = "";
|
||||||
|
expandedCategories = new Set();
|
||||||
|
isSubmitting = false;
|
||||||
|
submitError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains("modal-backdrop"))
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") handleClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
on:click={handleBackdropClick}
|
||||||
|
on:keydown={handleBackdropKeydown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={isEditMode ? "Edit Role" : "Create Role"}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title-group">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
>
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||||
|
</svg>
|
||||||
|
<h2>{isEditMode ? "Edit Role" : "Create Role"}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="close-btn"
|
||||||
|
on:click={handleClose}
|
||||||
|
type="button"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={isEditMode ? "?/updateRole" : "?/createRole"}
|
||||||
|
use:enhance={() => {
|
||||||
|
isSubmitting = true;
|
||||||
|
submitError = "";
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
isSubmitting = false;
|
||||||
|
if (result.type === "success") {
|
||||||
|
reset();
|
||||||
|
onSuccess();
|
||||||
|
} else if (result.type === "failure") {
|
||||||
|
submitError = String(
|
||||||
|
(result.data as Record<string, unknown>)?.message ??
|
||||||
|
(isEditMode
|
||||||
|
? "Failed to update role."
|
||||||
|
: "Failed to create role."),
|
||||||
|
);
|
||||||
|
} else if (result.type === "error") {
|
||||||
|
submitError =
|
||||||
|
result.error?.message ?? "An unexpected error occurred.";
|
||||||
|
}
|
||||||
|
await update({ reset: false });
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#each [...selectedPermissions] as node (node)}
|
||||||
|
<input type="hidden" name="permissions" value={node} />
|
||||||
|
{/each}
|
||||||
|
{#if isEditMode && roleToEdit}
|
||||||
|
<input type="hidden" name="id" value={roleToEdit.id} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{#if submitError}
|
||||||
|
<div class="error-banner">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="role-title">Title <span class="req">*</span></label>
|
||||||
|
<input
|
||||||
|
id="role-title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Administrator"
|
||||||
|
bind:value={title}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="role-moniker">
|
||||||
|
Moniker <span class="req">*</span>
|
||||||
|
{#if !isEditMode}<span class="label-hint">Auto-generated</span
|
||||||
|
>{/if}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="role-moniker"
|
||||||
|
name="moniker"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. administrator"
|
||||||
|
bind:value={moniker}
|
||||||
|
on:input={() => (monikerEdited = true)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="perm-section">
|
||||||
|
<div class="perm-section-header">
|
||||||
|
<span class="perm-label">Permissions</span>
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<span class="selected-badge">{selectedCount} selected</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-wrap">
|
||||||
|
<svg
|
||||||
|
class="search-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search permissions…"
|
||||||
|
bind:value={permissionSearch}
|
||||||
|
/>
|
||||||
|
{#if permissionSearch}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-clear"
|
||||||
|
on:click={() => (permissionSearch = "")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
width="11"
|
||||||
|
height="11"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="perm-list">
|
||||||
|
{#if Object.keys(permissionNodes).length === 0}
|
||||||
|
<p class="perm-empty">No permission data available.</p>
|
||||||
|
{:else if filteredEntries.length === 0}
|
||||||
|
<p class="perm-empty">No permissions match your search.</p>
|
||||||
|
{:else}
|
||||||
|
{#each filteredEntries as [catKey, category] (catKey)}
|
||||||
|
{@const catPerms = category.permissions}
|
||||||
|
{@const allSel =
|
||||||
|
catPerms.length > 0 &&
|
||||||
|
catPerms.every((p) => selectedPermissions.has(p.node))}
|
||||||
|
{@const someSel =
|
||||||
|
!allSel &&
|
||||||
|
catPerms.some((p) => selectedPermissions.has(p.node))}
|
||||||
|
{@const isExpanded =
|
||||||
|
permissionSearch.trim().length > 0 ||
|
||||||
|
expandedCategories.has(catKey)}
|
||||||
|
<div class="cat-group">
|
||||||
|
<div class="cat-header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cat-toggle"
|
||||||
|
on:click={() => toggleCategory(catKey)}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="chevron"
|
||||||
|
class:rotated={isExpanded}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
width="11"
|
||||||
|
height="11"
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
<span class="cat-name">{category.name}</span>
|
||||||
|
<span class="cat-count">{catPerms.length}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cat-all-btn"
|
||||||
|
on:click={() => toggleAllInCategory(catPerms)}
|
||||||
|
title={allSel
|
||||||
|
? "Deselect all in category"
|
||||||
|
: "Select all in category"}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="cb"
|
||||||
|
class:cb-checked={allSel}
|
||||||
|
class:cb-indeterminate={someSel}
|
||||||
|
>
|
||||||
|
{#if allSel}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="8"
|
||||||
|
height="8"
|
||||||
|
>
|
||||||
|
<polyline points="1.5 5 4 8 8.5 2" />
|
||||||
|
</svg>
|
||||||
|
{:else if someSel}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
width="8"
|
||||||
|
height="8"
|
||||||
|
>
|
||||||
|
<line x1="1.5" y1="5" x2="8.5" y2="5" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isExpanded}
|
||||||
|
<div class="cat-perms">
|
||||||
|
{#each catPerms as perm (perm.node)}
|
||||||
|
{@const sel = selectedPermissions.has(perm.node)}
|
||||||
|
<label class="perm-row" class:perm-sel={sel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="sr-only"
|
||||||
|
checked={sel}
|
||||||
|
on:change={() => togglePermission(perm.node)}
|
||||||
|
/>
|
||||||
|
<div class="cb" class:cb-checked={sel}>
|
||||||
|
{#if sel}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="8"
|
||||||
|
height="8"
|
||||||
|
>
|
||||||
|
<polyline points="1.5 5 4 8 8.5 2" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="perm-text">
|
||||||
|
<code class="perm-node">{perm.node}</code>
|
||||||
|
<span class="perm-desc">{perm.description}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-cancel"
|
||||||
|
on:click={handleClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-create"
|
||||||
|
disabled={!isValid || isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? isEditMode
|
||||||
|
? "Saving…"
|
||||||
|
: "Creating…"
|
||||||
|
: isEditMode
|
||||||
|
? "Save Changes"
|
||||||
|
: "Create Role"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: modalIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 20px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-group svg {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-group h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form wraps body + footer so the submit btn works ── */
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error banner ── */
|
||||||
|
.error-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(220, 38, 38, 0.08);
|
||||||
|
border: 1px solid rgba(220, 38, 38, 0.2);
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #dc2626;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form fields ── */
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.req {
|
||||||
|
color: var(--accent-color, #0066cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
border-color: var(--accent-color, #0066cc);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Permissions section ── */
|
||||||
|
.perm-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-label {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Search ── */
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px 7px 29px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: var(--accent-color, #0066cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Permission list ── */
|
||||||
|
.perm-list {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 240px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Category group ── */
|
||||||
|
.cat-group {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-group:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-toggle:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron.rotated {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-all-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition:
|
||||||
|
background 0.1s,
|
||||||
|
border-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-all-btn:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
border-color: var(--border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Custom checkbox ── */
|
||||||
|
.cb {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border: 1.5px solid var(--border-default);
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: #fff;
|
||||||
|
transition:
|
||||||
|
background 0.1s,
|
||||||
|
border-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb.cb-checked {
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
border-color: var(--accent-color, #0066cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb.cb-indeterminate {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-color: var(--accent-color, #0066cc);
|
||||||
|
color: var(--accent-color, #0066cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Permission rows ── */
|
||||||
|
.cat-perms {
|
||||||
|
padding: 2px 0 4px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 6px 12px 6px 26px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-row:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-row.perm-sel {
|
||||||
|
background: rgba(0, 102, 204, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-row .cb {
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-node {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel,
|
||||||
|
.btn-create {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover:not(:disabled) {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
border-color: var(--border-default);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-create {
|
||||||
|
background: var(--accent-color, #0066cc);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-create:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:disabled,
|
||||||
|
.btn-create:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,8 @@ export const optima = {
|
|||||||
credential: (await import("./optima-api/modules/credentials")).credential,
|
credential: (await import("./optima-api/modules/credentials")).credential,
|
||||||
credentialType: (await import("./optima-api/modules/credentialTypes"))
|
credentialType: (await import("./optima-api/modules/credentialTypes"))
|
||||||
.credentialType,
|
.credentialType,
|
||||||
|
role: (await import("./optima-api/modules/roles")).role,
|
||||||
|
permission: (await import("./optima-api/modules/permissions")).permission,
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import api from "../axios";
|
import api from "../axios";
|
||||||
|
|
||||||
export const company = {
|
export const company = {
|
||||||
async fetch(accessToken: string, id: string) {
|
async fetch(
|
||||||
|
accessToken: string,
|
||||||
|
id: string,
|
||||||
|
options?: { includeAddress?: boolean },
|
||||||
|
) {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (options?.includeAddress) params.includeAddress = "true";
|
||||||
|
|
||||||
const company = await api.get(`/v1/company/companies/${id}`, {
|
const company = await api.get(`/v1/company/companies/${id}`, {
|
||||||
|
params,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
@@ -27,6 +35,14 @@ export const company = {
|
|||||||
|
|
||||||
return companies.data;
|
return companies.data;
|
||||||
},
|
},
|
||||||
|
async count(accessToken: string) {
|
||||||
|
const response = await api.get("/v1/company/count", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data.data.count;
|
||||||
|
},
|
||||||
async fetchConfigurations(accessToken: string, id: string) {
|
async fetchConfigurations(accessToken: string, id: string) {
|
||||||
const configurations = await api.get(
|
const configurations = await api.get(
|
||||||
`/v1/company/companies/${id}/configurations`,
|
`/v1/company/companies/${id}/configurations`,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import api from "../axios";
|
||||||
|
|
||||||
|
export interface PermissionNode {
|
||||||
|
node: string;
|
||||||
|
description: string;
|
||||||
|
usedIn: string[];
|
||||||
|
dependencies?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionCategory {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
permissions: PermissionNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionsCategorized {
|
||||||
|
[category: string]: PermissionCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const permission = {
|
||||||
|
async fetchCategorized(accessToken: string) {
|
||||||
|
const response = await api.get("/v1/permissions", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchFlat(accessToken: string) {
|
||||||
|
const response = await api.get("/v1/permissions/nodes", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchByCategory(accessToken: string, category: string) {
|
||||||
|
const response = await api.get(`/v1/permissions/${category}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import api from "../axios";
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
moniker: string;
|
||||||
|
permissions: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const role = {
|
||||||
|
async fetchMany(accessToken: string) {
|
||||||
|
const response = await api.get("/v1/role", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetch(accessToken: string, identifier: string) {
|
||||||
|
const response = await api.get(`/v1/role/${identifier}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(
|
||||||
|
accessToken: string,
|
||||||
|
data: Omit<Role, "id" | "createdAt" | "updatedAt">,
|
||||||
|
) {
|
||||||
|
const response = await api.post("/v1/role", data, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
accessToken: string,
|
||||||
|
identifier: string,
|
||||||
|
updates: Partial<Omit<Role, "id" | "createdAt" | "updatedAt">>,
|
||||||
|
) {
|
||||||
|
const response = await api.patch(`/v1/role/${identifier}`, updates, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(accessToken: string, identifier: string) {
|
||||||
|
const response = await api.delete(`/v1/role/${identifier}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async addPermissions(
|
||||||
|
accessToken: string,
|
||||||
|
identifier: string,
|
||||||
|
permissions: string[],
|
||||||
|
) {
|
||||||
|
const response = await api.post(
|
||||||
|
`/v1/role/${identifier}/permissions`,
|
||||||
|
{ permissions },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async removePermissions(
|
||||||
|
accessToken: string,
|
||||||
|
identifier: string,
|
||||||
|
permissions: string[],
|
||||||
|
) {
|
||||||
|
const response = await api.delete(`/v1/role/${identifier}/permissions`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
data: { permissions },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchUsers(accessToken: string, identifier: string) {
|
||||||
|
const response = await api.get(`/v1/role/${identifier}/users`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { optima } from "$lib";
|
||||||
|
|
||||||
|
export type PermissionMap = Record<string, boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check multiple permissions for the current user and return a map of
|
||||||
|
* permission → boolean. Designed to be called from any +page.server.ts
|
||||||
|
* or +layout.server.ts load function.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const perms = await checkPermissions(accessToken, [
|
||||||
|
* "company.fetch.address",
|
||||||
|
* "credential.create",
|
||||||
|
* ]);
|
||||||
|
* // perms => { "company.fetch.address": true, "credential.create": false }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function checkPermissions(
|
||||||
|
accessToken: string,
|
||||||
|
permissions: string[],
|
||||||
|
): Promise<PermissionMap> {
|
||||||
|
if (!permissions.length) return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await optima.user.checkPermissions(accessToken, permissions);
|
||||||
|
|
||||||
|
const results: Array<{ permission: string; hasPermission: boolean }> =
|
||||||
|
result?.data?.results ?? [];
|
||||||
|
|
||||||
|
return results.reduce<PermissionMap>((map, entry) => {
|
||||||
|
map[entry.permission] = entry.hasPermission === true;
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Permission check failed:", err);
|
||||||
|
// Default every requested permission to false on failure
|
||||||
|
return permissions.reduce<PermissionMap>((map, p) => {
|
||||||
|
map[p] = false;
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience helper — returns true when a specific permission is
|
||||||
|
* granted inside a PermissionMap.
|
||||||
|
*/
|
||||||
|
export function hasPermission(map: PermissionMap, permission: string): boolean {
|
||||||
|
return map[permission] === true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark";
|
||||||
|
|
||||||
|
function createThemeStore() {
|
||||||
|
const initial: Theme = browser
|
||||||
|
? ((localStorage.getItem("theme") as Theme) ?? "dark")
|
||||||
|
: "dark";
|
||||||
|
|
||||||
|
const { subscribe, set, update } = writable<Theme>(initial);
|
||||||
|
|
||||||
|
function applyTheme(theme: Theme) {
|
||||||
|
if (browser) {
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
localStorage.setItem("theme", theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply on init
|
||||||
|
if (browser) applyTheme(initial);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
toggle() {
|
||||||
|
update((current) => {
|
||||||
|
const next = current === "dark" ? "light" : "dark";
|
||||||
|
applyTheme(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
set(theme: Theme) {
|
||||||
|
applyTheme(theme);
|
||||||
|
set(theme);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const theme = createThemeStore();
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { enhance } from "$app/forms";
|
import { enhance } from "$app/forms";
|
||||||
import LoadingSpinner from "../../../components/LoadingSpinner.svelte";
|
import LoadingSpinner from "../../../components/LoadingSpinner.svelte";
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
|
import { theme } from "$lib/theme";
|
||||||
|
|
||||||
const uriData = await optima.auth.fetchAuthRedirectUri(PUBLIC_API_URL);
|
const uriData = await optima.auth.fetchAuthRedirectUri(PUBLIC_API_URL);
|
||||||
let loading = writable(false);
|
let loading = writable(false);
|
||||||
@@ -34,6 +35,42 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<button
|
||||||
|
class="theme-toggle login-theme-toggle"
|
||||||
|
onclick={() => theme.toggle()}
|
||||||
|
aria-label="Toggle {$theme === 'dark' ? 'light' : 'dark'} mode"
|
||||||
|
title="Switch to {$theme === 'dark' ? 'light' : 'dark'} mode"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="theme-icon sun-icon"
|
||||||
|
class:visible={$theme === "dark"}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="5" />
|
||||||
|
<line x1="12" y1="1" x2="12" y2="3" />
|
||||||
|
<line x1="12" y1="21" x2="12" y2="23" />
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||||
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12" />
|
||||||
|
<line x1="21" y1="12" x2="23" y2="12" />
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||||
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
class="theme-icon moon-icon"
|
||||||
|
class:visible={$theme === "light"}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<form action="?/login" method="POST" onsubmit={handleSubmit} use:enhance>
|
<form action="?/login" method="POST" onsubmit={handleSubmit} use:enhance>
|
||||||
<input type="hidden" name="callbackKey" value={uriData.callbackKey} />
|
<input type="hidden" name="callbackKey" value={uriData.callbackKey} />
|
||||||
<button
|
<button
|
||||||
@@ -62,25 +99,39 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #f3f2f1;
|
background: var(--bg-base);
|
||||||
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
.ms-button {
|
.ms-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 18px;
|
padding: 12px 18px;
|
||||||
background: #2f2f2f;
|
background: var(--bg-elevated);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
border: none;
|
border: 1px solid var(--border-default);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
transition:
|
||||||
|
background 0.2s ease,
|
||||||
|
color 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.ms-button:hover:not([disabled]) {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
.ms-button[disabled] {
|
.ms-button[disabled] {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
.login-theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
.ms-logo {
|
.ms-logo {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -31,11 +31,11 @@
|
|||||||
cx="60"
|
cx="60"
|
||||||
cy="60"
|
cy="60"
|
||||||
r="56"
|
r="56"
|
||||||
fill="#fef2f2"
|
fill="var(--error-circle-outer)"
|
||||||
stroke="#fecaca"
|
stroke="var(--error-circle-stroke)"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
<circle cx="60" cy="60" r="40" fill="#fee2e2" />
|
<circle cx="60" cy="60" r="40" fill="var(--error-circle-inner)" />
|
||||||
<path
|
<path
|
||||||
d="M60 35v30"
|
d="M60 35v30"
|
||||||
stroke="#dc2626"
|
stroke="#dc2626"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { optima } from "$lib";
|
import { optima } from "$lib";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
|
import { theme } from "$lib/theme";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
@@ -36,6 +37,41 @@
|
|||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<h1>Project Optima</h1>
|
<h1>Project Optima</h1>
|
||||||
|
<button
|
||||||
|
class="theme-toggle"
|
||||||
|
on:click={() => theme.toggle()}
|
||||||
|
aria-label="Toggle {$theme === 'dark' ? 'light' : 'dark'} mode"
|
||||||
|
title="Switch to {$theme === 'dark' ? 'light' : 'dark'} mode"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="theme-icon sun-icon"
|
||||||
|
class:visible={$theme === "dark"}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="5" />
|
||||||
|
<line x1="12" y1="1" x2="12" y2="3" />
|
||||||
|
<line x1="12" y1="21" x2="12" y2="23" />
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||||
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12" />
|
||||||
|
<line x1="21" y1="12" x2="23" y2="12" />
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||||
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
class="theme-icon moon-icon"
|
||||||
|
class:visible={$theme === "light"}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { optima } from "$lib";
|
||||||
|
import { handleApiError } from "$lib/optima-api/errorHandler";
|
||||||
|
import { checkPermissions, type PermissionMap } from "$lib/permissions";
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
import type { LayoutServerLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
throw redirect(303, "/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check the top-level admin gate + all per-tab permissions in one call
|
||||||
|
const permissions = await checkPermissions(accessToken, [
|
||||||
|
"ui.navigation.admin.view",
|
||||||
|
"admin.users.view",
|
||||||
|
"admin.roles.view",
|
||||||
|
"admin.credential-types.view",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!permissions["ui.navigation.admin.view"]) {
|
||||||
|
throw redirect(303, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current user info for the dashboard greeting
|
||||||
|
const userInfo = await optima.user.fetchInfo(accessToken);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userInfo?.data ?? null,
|
||||||
|
permissions,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// Re-throw redirects so SvelteKit handles them
|
||||||
|
if (err && typeof err === "object" && "status" in err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
handleApiError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import "../../styles/admin.css";
|
||||||
|
|
||||||
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
|
|
||||||
|
export let data: {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
permissions: PermissionMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
$: permissions = data.permissions;
|
||||||
|
$: userName = data.user?.name || "Admin";
|
||||||
|
|
||||||
|
// Tab definitions — each gated by a permission
|
||||||
|
const allTabs = [
|
||||||
|
{ label: "Overview", href: "/admin", exact: true, permission: null },
|
||||||
|
{
|
||||||
|
label: "Users",
|
||||||
|
href: "/admin/users",
|
||||||
|
exact: false,
|
||||||
|
permission: "admin.users.view",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Roles",
|
||||||
|
href: "/admin/roles",
|
||||||
|
exact: false,
|
||||||
|
permission: "admin.roles.view",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Credential Types",
|
||||||
|
href: "/admin/credential-types",
|
||||||
|
exact: false,
|
||||||
|
permission: "admin.credential-types.view",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Only show tabs the user has permission for
|
||||||
|
$: visibleTabs = allTabs.filter(
|
||||||
|
(t) => t.permission === null || permissions[t.permission] === true,
|
||||||
|
);
|
||||||
|
|
||||||
|
function isActive(
|
||||||
|
tab: { href: string; exact?: boolean },
|
||||||
|
pathname: string,
|
||||||
|
): boolean {
|
||||||
|
if (tab.exact) return pathname === tab.href;
|
||||||
|
return pathname.startsWith(tab.href);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Admin — Project Optima</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="admin-page">
|
||||||
|
<div class="admin-pane">
|
||||||
|
<!-- Pane header -->
|
||||||
|
<div class="admin-header">
|
||||||
|
<div class="admin-header-left">
|
||||||
|
<svg
|
||||||
|
class="admin-header-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path
|
||||||
|
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h2 class="admin-title">Administration</h2>
|
||||||
|
<span class="admin-subtitle">Welcome back, {userName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div class="tab-bar" role="tablist">
|
||||||
|
{#each visibleTabs as tab}
|
||||||
|
<a
|
||||||
|
href={tab.href}
|
||||||
|
class="tab-btn"
|
||||||
|
class:active={isActive(tab, $page.url.pathname)}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive(tab, $page.url.pathname)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab content -->
|
||||||
|
<div class="admin-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { optima } from "$lib";
|
||||||
|
import { handleApiError } from "$lib/optima-api/errorHandler";
|
||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return { companyCount: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const companyCount = await optima.company.count(accessToken);
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyCount: companyCount ?? null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
handleApiError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
export let data: {
|
||||||
|
companyCount: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
$: companyCount = data.companyCount;
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{
|
||||||
|
href: "/companies",
|
||||||
|
name: "Companies",
|
||||||
|
desc: "View and manage companies",
|
||||||
|
icon: '<path d="M3 21h18"></path><path d="M5 21V7l8-4v18"></path><path d="M19 21V11l-6-4"></path><path d="M9 9h1"></path><path d="M9 13h1"></path><path d="M9 17h1"></path>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/users",
|
||||||
|
name: "Manage Users",
|
||||||
|
desc: "View, edit, and assign user roles",
|
||||||
|
icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/roles",
|
||||||
|
name: "Manage Roles",
|
||||||
|
desc: "Configure roles and permissions",
|
||||||
|
icon: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/credential-types",
|
||||||
|
name: "Credential Types",
|
||||||
|
desc: "Configure credential type definitions",
|
||||||
|
icon: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Stats overview -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
||||||
|
<path d="M3 21h18" />
|
||||||
|
<path d="M5 21V7l8-4v18" />
|
||||||
|
<path d="M19 21V11l-6-4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">{companyCount ?? "—"}</span>
|
||||||
|
<span class="stat-label">Companies</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">—</span>
|
||||||
|
<span class="stat-label">Users</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">—</span>
|
||||||
|
<span class="stat-label">Credentials</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">—</span>
|
||||||
|
<span class="stat-label">Activity Today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick actions -->
|
||||||
|
<h3 class="section-heading">Quick Actions</h3>
|
||||||
|
<div class="actions-grid">
|
||||||
|
{#each quickActions as action}
|
||||||
|
<a
|
||||||
|
href={action.href}
|
||||||
|
class="action-card"
|
||||||
|
on:click|preventDefault={() => goto(action.href)}
|
||||||
|
>
|
||||||
|
<div class="action-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
||||||
|
{@html action.icon}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="action-text">
|
||||||
|
<span class="action-name">{action.name}</span>
|
||||||
|
<span class="action-desc">{action.desc}</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="action-arrow"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent activity placeholder -->
|
||||||
|
<h3 class="section-heading">Recent Activity</h3>
|
||||||
|
<div class="activity-section">
|
||||||
|
<div class="activity-empty">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<span>Activity feed coming soon</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
|
|
||||||
|
export let data: { permissions: PermissionMap };
|
||||||
|
|
||||||
|
$: hasAccess = data.permissions["admin.credential-types.view"] === true;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !hasAccess}
|
||||||
|
<div class="admin-denied">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||||
|
</svg>
|
||||||
|
<h3>Access Denied</h3>
|
||||||
|
<p>
|
||||||
|
You don't have permission to manage credential types. Contact your
|
||||||
|
administrator to request access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="admin-tab-empty">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
<h3>Credential Type Management</h3>
|
||||||
|
<p>
|
||||||
|
Credential type definitions and configuration will be wired up here.
|
||||||
|
Connect an API module to populate this view.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { optima } from "$lib";
|
||||||
|
import { handleApiError } from "$lib/optima-api/errorHandler";
|
||||||
|
import { checkPermissions } from "$lib/permissions";
|
||||||
|
import { fail } from "@sveltejs/kit";
|
||||||
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return { roles: [], permissions: {}, permissionNodes: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rolesResult, permissions, permNodesResult] = await Promise.all([
|
||||||
|
optima.role.fetchMany(accessToken),
|
||||||
|
checkPermissions(accessToken, [
|
||||||
|
"admin.roles.view",
|
||||||
|
"admin.roles.create",
|
||||||
|
"admin.roles.edit",
|
||||||
|
"admin.roles.delete",
|
||||||
|
]),
|
||||||
|
optima.permission
|
||||||
|
.fetchCategorized(accessToken)
|
||||||
|
.catch(() => ({ data: {} })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const roles = rolesResult?.data ?? [];
|
||||||
|
|
||||||
|
// Fetch users for each role in parallel
|
||||||
|
const rolesWithUsers = await Promise.all(
|
||||||
|
roles.map(async (role: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
const usersResult = await optima.role.fetchUsers(
|
||||||
|
accessToken,
|
||||||
|
role.id as string,
|
||||||
|
);
|
||||||
|
return { ...role, users: usersResult?.data ?? [] };
|
||||||
|
} catch {
|
||||||
|
return { ...role, users: [] };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles: rolesWithUsers,
|
||||||
|
permissions,
|
||||||
|
permissionNodes: permNodesResult?.data ?? {},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
handleApiError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
createRole: async ({ locals, request }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return fail(401, { message: "Not authenticated." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const title = (formData.get("title") as string)?.trim();
|
||||||
|
const moniker = (formData.get("moniker") as string)?.trim();
|
||||||
|
const permissions = formData.getAll("permissions") as string[];
|
||||||
|
|
||||||
|
if (!title || !moniker) {
|
||||||
|
return fail(400, { message: "Title and moniker are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await optima.role.create(accessToken, { title, moniker, permissions });
|
||||||
|
return {};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const data = (err as AxiosError)?.response?.data as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const message =
|
||||||
|
(data?.message as string) ??
|
||||||
|
(err instanceof Error ? err.message : "Failed to create role.");
|
||||||
|
const status = (data?.status as number) ?? 500;
|
||||||
|
return fail(status, { message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRole: async ({ locals, request }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return fail(401, { message: "Not authenticated." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = (formData.get("id") as string)?.trim();
|
||||||
|
const title = (formData.get("title") as string)?.trim();
|
||||||
|
const moniker = (formData.get("moniker") as string)?.trim();
|
||||||
|
const permissions = formData.getAll("permissions") as string[];
|
||||||
|
|
||||||
|
if (!id || !title || !moniker) {
|
||||||
|
return fail(400, { message: "Required fields are missing." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await optima.role.update(accessToken, id, {
|
||||||
|
title,
|
||||||
|
moniker,
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const data = (err as AxiosError)?.response?.data as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const message =
|
||||||
|
(data?.message as string) ??
|
||||||
|
(err instanceof Error ? err.message : "Failed to update role.");
|
||||||
|
const status = (data?.status as number) ?? 500;
|
||||||
|
return fail(status, { message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRole: async ({ locals, request }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return fail(401, { message: "Not authenticated." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = (formData.get("id") as string)?.trim();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { message: "Role ID is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await optima.role.delete(accessToken, id);
|
||||||
|
return {};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const data = (err as AxiosError)?.response?.data as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const message =
|
||||||
|
(data?.message as string) ??
|
||||||
|
(err instanceof Error ? err.message : "Failed to delete role.");
|
||||||
|
const status = (data?.status as number) ?? 500;
|
||||||
|
return fail(status, { message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
|
|
||||||
|
export let data: { permissions: PermissionMap };
|
||||||
|
|
||||||
|
$: hasAccess = data.permissions["admin.users.view"] === true;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !hasAccess}
|
||||||
|
<div class="admin-denied">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||||
|
</svg>
|
||||||
|
<h3>Access Denied</h3>
|
||||||
|
<p>
|
||||||
|
You don't have permission to manage users. Contact your administrator to
|
||||||
|
request access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="admin-tab-empty">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
<h3>User Management</h3>
|
||||||
|
<p>
|
||||||
|
User listing and editing will be wired up here. Connect an API module to
|
||||||
|
populate this view.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -25,14 +25,19 @@
|
|||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
let isSearching = false;
|
let isSearching = false;
|
||||||
let searchInputEl: HTMLInputElement;
|
let searchInputEl: HTMLInputElement;
|
||||||
|
let searchStartedAt = 0;
|
||||||
|
|
||||||
// When navigation completes (results loaded), clear loading & refocus
|
// When navigation completes (results loaded), clear loading & refocus
|
||||||
|
// Ensure spinner stays visible for at least 500ms
|
||||||
afterNavigate(() => {
|
afterNavigate(() => {
|
||||||
isSearching = false;
|
const elapsed = Date.now() - searchStartedAt;
|
||||||
if (searchInputEl && document.activeElement !== searchInputEl) {
|
const remaining = Math.max(0, 500 - elapsed);
|
||||||
// Use tick to ensure DOM is settled
|
setTimeout(() => {
|
||||||
requestAnimationFrame(() => searchInputEl?.focus());
|
isSearching = false;
|
||||||
}
|
if (searchInputEl && document.activeElement !== searchInputEl) {
|
||||||
|
requestAnimationFrame(() => searchInputEl?.focus());
|
||||||
|
}
|
||||||
|
}, remaining);
|
||||||
});
|
});
|
||||||
|
|
||||||
$: currentPage = data.currentPage;
|
$: currentPage = data.currentPage;
|
||||||
@@ -49,6 +54,7 @@
|
|||||||
|
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
isSearching = true;
|
isSearching = true;
|
||||||
|
searchStartedAt = Date.now();
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -61,6 +67,7 @@
|
|||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
isSearching = true;
|
isSearching = true;
|
||||||
|
searchStartedAt = Date.now();
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { optima } from "$lib";
|
||||||
|
import { handleApiError } from "$lib/optima-api/errorHandler";
|
||||||
|
import { checkPermissions, type PermissionMap } from "$lib/permissions";
|
||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return {
|
||||||
|
company: null,
|
||||||
|
configurations: [],
|
||||||
|
permissions: {} as PermissionMap,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Run permission checks in parallel with other data fetches.
|
||||||
|
// Add any new permissions the company page needs to this array.
|
||||||
|
const [permissions, configsResult] = await Promise.all([
|
||||||
|
checkPermissions(accessToken, ["company.fetch.address"]),
|
||||||
|
optima.company.fetchConfigurations(accessToken, params.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fetch company with or without address based on permission
|
||||||
|
const companyResult = await optima.company.fetch(accessToken, params.id, {
|
||||||
|
includeAddress: permissions["company.fetch.address"] === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
company: companyResult?.data ?? null,
|
||||||
|
configurations: configsResult?.data ?? [],
|
||||||
|
permissions,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
handleApiError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,712 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import "../../../styles/companies/companydetail.css";
|
||||||
|
|
||||||
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
|
|
||||||
|
export let data: {
|
||||||
|
company: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status?: string;
|
||||||
|
type?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
identifier?: string;
|
||||||
|
contactEmail?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
address?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
cw_Data?: {
|
||||||
|
address?: {
|
||||||
|
line1?: string;
|
||||||
|
line2?: string | null;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
configurations: Array<{
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
active?: boolean;
|
||||||
|
serialNumber?: string;
|
||||||
|
status?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
type?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
_info?: { type_href?: string };
|
||||||
|
};
|
||||||
|
notes?: string;
|
||||||
|
questions?: Array<{
|
||||||
|
id: number;
|
||||||
|
question: string;
|
||||||
|
answer?: string;
|
||||||
|
fieldType: string;
|
||||||
|
}> | null;
|
||||||
|
info?: {
|
||||||
|
lastUpdated?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
dateEntered?: string;
|
||||||
|
enteredBy?: string;
|
||||||
|
};
|
||||||
|
key?: string;
|
||||||
|
value?: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}>;
|
||||||
|
permissions: PermissionMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
$: company = data.company;
|
||||||
|
$: configurations = data.configurations;
|
||||||
|
$: permissions = data.permissions;
|
||||||
|
|
||||||
|
const tabs = ["Credentials", "Configurations", "Users", "Activity"] as const;
|
||||||
|
type Tab = (typeof tabs)[number];
|
||||||
|
let activeTab: Tab = "Credentials";
|
||||||
|
|
||||||
|
// Configurations split-view state
|
||||||
|
let selectedConfig: (typeof configurations)[number] | null = null;
|
||||||
|
let configFadeKey = 0;
|
||||||
|
|
||||||
|
// Track which password fields are revealed (by question id)
|
||||||
|
let revealedPasswords: Record<number, boolean> = {};
|
||||||
|
|
||||||
|
function togglePassword(questionId: number) {
|
||||||
|
revealedPasswords[questionId] = !revealedPasswords[questionId];
|
||||||
|
revealedPasswords = revealedPasswords; // trigger reactivity
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectConfig(config: (typeof configurations)[number]) {
|
||||||
|
if (selectedConfig?.id === config.id) {
|
||||||
|
// Clicking the active config collapses back to full list
|
||||||
|
selectedConfig = null;
|
||||||
|
} else {
|
||||||
|
selectedConfig = config;
|
||||||
|
configFadeKey++; // bump key to trigger fade animation
|
||||||
|
revealedPasswords = {}; // reset password visibility
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function companyInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status?: string): string {
|
||||||
|
if (!status) return "neutral";
|
||||||
|
const s = status.toLowerCase();
|
||||||
|
if (s === "active") return "active";
|
||||||
|
if (s === "inactive" || s === "disabled") return "inactive";
|
||||||
|
if (s === "pending") return "pending";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
function configStatusClass(statusName?: string): string {
|
||||||
|
if (!statusName) return "neutral";
|
||||||
|
const s = statusName.toLowerCase();
|
||||||
|
if (s === "active") return "active";
|
||||||
|
if (s === "inactive" || s === "automate inactive") return "inactive";
|
||||||
|
if (s === "reserved") return "reserved";
|
||||||
|
if (s === "provisioning" || s === "pending approval") return "pending";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr?: string): string {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(c: NonNullable<typeof company>): string[] {
|
||||||
|
// Prefer the nested cw_Data.address structure returned when includeAddress=true
|
||||||
|
const addr = c.cw_Data?.address;
|
||||||
|
if (addr) {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (addr.line1) lines.push(addr.line1);
|
||||||
|
if (addr.line2) lines.push(addr.line2);
|
||||||
|
const cityStateZip = [addr.city, addr.state, addr.zip]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
if (cityStateZip) lines.push(cityStateZip);
|
||||||
|
if (addr.country) lines.push(addr.country);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
// Fallback to flat fields
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (c.address) lines.push(c.address);
|
||||||
|
const cityStateZip = [c.city, c.state, c.zip].filter(Boolean).join(", ");
|
||||||
|
if (cityStateZip) lines.push(cityStateZip);
|
||||||
|
if (c.country) lines.push(c.country);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{company?.name ?? "Company"} — Project Optima</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="company-detail-page">
|
||||||
|
<!-- Left pane (1/4) — Company overview -->
|
||||||
|
<div class="company-detail-left">
|
||||||
|
<div class="detail-pane-body">
|
||||||
|
<button
|
||||||
|
class="back-btn"
|
||||||
|
on:click={() => goto("/companies")}
|
||||||
|
aria-label="Back to companies"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if company}
|
||||||
|
<!-- Avatar + name + status -->
|
||||||
|
<div class="profile-header">
|
||||||
|
<div class="profile-avatar">
|
||||||
|
<span class="profile-initials">{companyInitials(company.name)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<h3 class="profile-name">{company.name}</h3>
|
||||||
|
{#if company.status}
|
||||||
|
<span class="profile-status {statusClass(company.status)}"
|
||||||
|
>{company.status}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info rows -->
|
||||||
|
<div class="profile-info">
|
||||||
|
{#if company.type}
|
||||||
|
<div class="info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="info-icon"
|
||||||
|
>
|
||||||
|
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
|
||||||
|
<path d="M16 3h-8l-2 4h12z" />
|
||||||
|
</svg>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Type</span>
|
||||||
|
<span class="info-value">{company.type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if company.identifier || company.id}
|
||||||
|
<div class="info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="info-icon"
|
||||||
|
>
|
||||||
|
<path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18" />
|
||||||
|
</svg>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Identifier</span>
|
||||||
|
<span class="info-value mono"
|
||||||
|
>{company.identifier || company.id}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if company.contactEmail}
|
||||||
|
<div class="info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="info-icon"
|
||||||
|
>
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
|
<path d="M22 7l-10 7L2 7" />
|
||||||
|
</svg>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Email</span>
|
||||||
|
<span class="info-value">{company.contactEmail}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if company.contactPhone}
|
||||||
|
<div class="info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="info-icon"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Phone</span>
|
||||||
|
<span class="info-value">{company.contactPhone}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if permissions["company.fetch.address"] && formatAddress(company).length > 0}
|
||||||
|
<div class="info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="info-icon"
|
||||||
|
>
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
|
||||||
|
<circle cx="12" cy="10" r="3" />
|
||||||
|
</svg>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Address</span>
|
||||||
|
<span class="info-value address-multiline">
|
||||||
|
{#each formatAddress(company) as line}
|
||||||
|
{line}<br />
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if formatDate(company.createdAt)}
|
||||||
|
<div class="info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="info-icon"
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Created</span>
|
||||||
|
<span class="info-value">{formatDate(company.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if formatDate(company.updatedAt)}
|
||||||
|
<div class="info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="info-icon"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Updated</span>
|
||||||
|
<span class="info-value">{formatDate(company.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="profile-empty">
|
||||||
|
<p>Company not found.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right pane (3/4) -->
|
||||||
|
<div class="company-detail-right">
|
||||||
|
<div class="tab-bar" role="tablist">
|
||||||
|
{#each tabs as tab}
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
class:active={activeTab === tab}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab}
|
||||||
|
on:click={() => (activeTab = tab)}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
{#if tab === "Configurations" && configurations.length > 0}
|
||||||
|
<span class="tab-count-badge">{configurations.length}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="detail-pane-body">
|
||||||
|
{#if activeTab === "Credentials"}
|
||||||
|
<p class="tab-placeholder">Credentials content</p>
|
||||||
|
{:else if activeTab === "Configurations"}
|
||||||
|
{#if configurations.length === 0}
|
||||||
|
<div class="tab-empty">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
class="tab-empty-icon"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p>No configurations found</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="config-split" class:expanded={selectedConfig !== null}>
|
||||||
|
<!-- Left side: config buttons -->
|
||||||
|
<div class="config-list" class:collapsed={selectedConfig !== null}>
|
||||||
|
{#each configurations as config (config.id)}
|
||||||
|
<button
|
||||||
|
class="config-item"
|
||||||
|
class:selected={selectedConfig?.id === config.id}
|
||||||
|
class:config-inactive={config.status?.name === "Inactive" ||
|
||||||
|
config.status?.name === "Automate Inactive"}
|
||||||
|
on:click={() => selectConfig(config)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div class="config-item-header">
|
||||||
|
<div class="config-name-group">
|
||||||
|
<span
|
||||||
|
class="config-status-dot dot-{configStatusClass(
|
||||||
|
config.status?.name,
|
||||||
|
)}"
|
||||||
|
title={config.status?.name ?? "Unknown"}
|
||||||
|
></span>
|
||||||
|
<span class="config-name">{config.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-header-badges">
|
||||||
|
{#if config.status?.name && !selectedConfig}
|
||||||
|
<span
|
||||||
|
class="config-status-badge status-{configStatusClass(
|
||||||
|
config.status.name,
|
||||||
|
)}">{config.status.name}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if config.type?.name && !selectedConfig}
|
||||||
|
<span class="config-type-badge">{config.type.name}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if !selectedConfig}
|
||||||
|
{#if config.description}
|
||||||
|
<p class="config-description">{config.description}</p>
|
||||||
|
{/if}
|
||||||
|
{#if config.key}
|
||||||
|
<div class="config-kv">
|
||||||
|
<span class="config-key">{config.key}</span>
|
||||||
|
{#if config.value}
|
||||||
|
<span class="config-value">{config.value}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if formatDate(config.updatedAt) || formatDate(config.createdAt) || formatDate(config.info?.lastUpdated) || formatDate(config.info?.dateEntered)}
|
||||||
|
<span class="config-date">
|
||||||
|
{#if formatDate(config.updatedAt)}
|
||||||
|
Updated {formatDate(config.updatedAt)}
|
||||||
|
{:else if formatDate(config.info?.lastUpdated)}
|
||||||
|
Updated {formatDate(config.info?.lastUpdated)}
|
||||||
|
{:else if formatDate(config.createdAt)}
|
||||||
|
Created {formatDate(config.createdAt)}
|
||||||
|
{:else if formatDate(config.info?.dateEntered)}
|
||||||
|
Created {formatDate(config.info?.dateEntered)}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right side: config detail panel -->
|
||||||
|
{#if selectedConfig}
|
||||||
|
<div class="config-detail-panel">
|
||||||
|
{#key configFadeKey}
|
||||||
|
<div class="config-detail-content">
|
||||||
|
<div class="config-detail-header">
|
||||||
|
<div class="config-detail-header-left">
|
||||||
|
<h3 class="config-detail-title">
|
||||||
|
{selectedConfig.name}
|
||||||
|
</h3>
|
||||||
|
<div class="config-detail-meta-badges">
|
||||||
|
{#if selectedConfig.type?.name}
|
||||||
|
<span class="config-badge type"
|
||||||
|
>{selectedConfig.type.name}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if selectedConfig.status?.name}
|
||||||
|
<span
|
||||||
|
class="config-badge status-{configStatusClass(
|
||||||
|
selectedConfig.status.name,
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
{selectedConfig.status.name}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="config-detail-close"
|
||||||
|
on:click={() => (selectedConfig = null)}
|
||||||
|
aria-label="Close detail view"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedConfig.serialNumber}
|
||||||
|
<div class="config-serial">
|
||||||
|
<span class="config-serial-label">Serial #</span>
|
||||||
|
<span class="config-serial-value"
|
||||||
|
>{selectedConfig.serialNumber}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
{#if selectedConfig.notes}
|
||||||
|
<div class="config-notes">
|
||||||
|
<h4 class="config-section-title">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
|
||||||
|
/>
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10 9 9 9 8 9" />
|
||||||
|
</svg>
|
||||||
|
Notes
|
||||||
|
</h4>
|
||||||
|
<p class="config-notes-text">{selectedConfig.notes}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Questions / Fields -->
|
||||||
|
{#if selectedConfig.questions && selectedConfig.questions.length > 0}
|
||||||
|
<div class="config-questions">
|
||||||
|
<h4 class="config-section-title">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
|
||||||
|
/>
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||||
|
</svg>
|
||||||
|
Configuration Details
|
||||||
|
</h4>
|
||||||
|
<div class="questions-grid">
|
||||||
|
{#each selectedConfig.questions as q (q.id)}
|
||||||
|
<div
|
||||||
|
class="question-row"
|
||||||
|
class:has-answer={!!q.answer}
|
||||||
|
>
|
||||||
|
<span class="question-label">{q.question}</span>
|
||||||
|
<div class="question-value-wrap">
|
||||||
|
{#if q.fieldType === "Password"}
|
||||||
|
<span class="question-value password-value">
|
||||||
|
{#if revealedPasswords[q.id]}
|
||||||
|
{q.answer || "—"}
|
||||||
|
{:else}
|
||||||
|
{q.answer ? "••••••••" : "—"}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if q.answer}
|
||||||
|
<button
|
||||||
|
class="password-toggle"
|
||||||
|
on:click={() => togglePassword(q.id)}
|
||||||
|
type="button"
|
||||||
|
aria-label={revealedPasswords[q.id]
|
||||||
|
? "Hide password"
|
||||||
|
: "Show password"}
|
||||||
|
>
|
||||||
|
{#if revealedPasswords[q.id]}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14.12 14.12a3 3 0 11-4.24-4.24"
|
||||||
|
/>
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else if q.fieldType === "TextArea"}
|
||||||
|
<span class="question-value textarea-value"
|
||||||
|
>{q.answer || "—"}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="question-value"
|
||||||
|
>{q.answer || "—"}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !selectedConfig.notes}
|
||||||
|
<div class="config-no-questions">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
<p>No configuration fields available</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Footer metadata -->
|
||||||
|
{#if selectedConfig.info}
|
||||||
|
<div class="config-info-footer">
|
||||||
|
{#if selectedConfig.info.enteredBy || selectedConfig.info.dateEntered}
|
||||||
|
<div class="config-info-item">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
Created{#if selectedConfig.info.enteredBy} by <strong
|
||||||
|
>{selectedConfig.info.enteredBy}</strong
|
||||||
|
>{/if}{#if selectedConfig.info.dateEntered} on
|
||||||
|
{formatDate(selectedConfig.info.dateEntered)}{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if selectedConfig.info.updatedBy || selectedConfig.info.lastUpdated}
|
||||||
|
<div class="config-info-item">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
Updated{#if selectedConfig.info.updatedBy} by <strong
|
||||||
|
>{selectedConfig.info.updatedBy}</strong
|
||||||
|
>{/if}{#if selectedConfig.info.lastUpdated} on
|
||||||
|
{formatDate(selectedConfig.info.lastUpdated)}{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if activeTab === "Users"}
|
||||||
|
<p class="tab-placeholder">Users content</p>
|
||||||
|
{:else if activeTab === "Activity"}
|
||||||
|
<p class="tab-placeholder">Activity content</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Admin — Pane + Tab Bar Layout
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Page container */
|
||||||
|
.admin-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pane container ── */
|
||||||
|
.admin-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--header-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pane header ── */
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab bar (mirrors companydetail.css) ── */
|
||||||
|
.admin-pane .tab-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pane .tab-btn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pane .tab-btn::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pane .tab-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pane .tab-btn.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pane .tab-btn.active::after {
|
||||||
|
background: var(--input-focus-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pane body (tab content area) ── */
|
||||||
|
.admin-body {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats grid ── */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
border-color: var(--card-hover-border);
|
||||||
|
box-shadow: var(--card-hover-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--avatar-gradient-from),
|
||||||
|
var(--avatar-gradient-to)
|
||||||
|
);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--text-inverse);
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section headings ── */
|
||||||
|
.section-heading {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Quick-action cards ── */
|
||||||
|
.actions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
border-color: var(--card-hover-border);
|
||||||
|
box-shadow: var(--card-hover-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:active {
|
||||||
|
box-shadow: var(--card-active-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--nav-active-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--nav-active-color);
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--card-arrow-color);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.2s,
|
||||||
|
transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover .action-arrow {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Activity placeholder ── */
|
||||||
|
.activity-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px dashed var(--border-default);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-empty svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ── */
|
||||||
|
.admin-body::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-body::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-body::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-body::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--scrollbar-thumb-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Admin — Tab Empty State
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.admin-tab-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tab-empty svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tab-empty h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tab-empty p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Admin — Permission Denied State
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.admin-denied {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-denied svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--status-inactive-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-denied h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-denied p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Admin — Data Tables (Users, Roles, Cred Types)
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.admin-table-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-header .result-count {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-wrap {
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table thead {
|
||||||
|
background: var(--bg-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr {
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:hover {
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
@@ -2,6 +2,226 @@
|
|||||||
@plugin '@tailwindcss/forms';
|
@plugin '@tailwindcss/forms';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Theme Variables
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
:root,
|
||||||
|
[data-theme="light"] {
|
||||||
|
/* Surfaces */
|
||||||
|
--bg-base: #f0f0f0;
|
||||||
|
--bg-surface: #ffffff;
|
||||||
|
--bg-surface-alt: #f8f9fb;
|
||||||
|
--bg-elevated: #ffffff;
|
||||||
|
--bg-inset: #f9fafb;
|
||||||
|
--bg-gradient-from: #3498db;
|
||||||
|
--bg-gradient-to: #2980b9;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-default: #e0e0e0;
|
||||||
|
--border-subtle: #eef0f3;
|
||||||
|
--border-strong: #d1d5db;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-primary: #2c3e50;
|
||||||
|
--text-secondary: #666666;
|
||||||
|
--text-muted: #8492a6;
|
||||||
|
--text-faint: #9ca3af;
|
||||||
|
--text-inverse: #ffffff;
|
||||||
|
|
||||||
|
/* Nav */
|
||||||
|
--nav-hover-bg: rgba(0, 0, 0, 0.04);
|
||||||
|
--nav-active-bg: rgba(52, 152, 219, 0.08);
|
||||||
|
--nav-active-color: #3498db;
|
||||||
|
--nav-active-border: #3498db;
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
--header-bg: #ffffff;
|
||||||
|
--header-text: #2c3e50;
|
||||||
|
--header-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
--accent-bar-from: #2980b9;
|
||||||
|
--accent-bar-to: #3498db;
|
||||||
|
--accent-bar-height: 0;
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
--card-bg: #f8f9fb;
|
||||||
|
--card-hover-bg: #ffffff;
|
||||||
|
--card-border: #eef0f3;
|
||||||
|
--card-hover-border: #d0d9e8;
|
||||||
|
--card-hover-shadow:
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
--card-active-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
--card-focus-ring: #3498db;
|
||||||
|
--card-arrow-color: #cbd5e1;
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
--avatar-gradient-from: #3498db;
|
||||||
|
--avatar-gradient-to: #2980b9;
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
--input-bg: #f9fafb;
|
||||||
|
--input-focus-bg: #ffffff;
|
||||||
|
--input-border: #d1d5db;
|
||||||
|
--input-focus-border: #3498db;
|
||||||
|
--input-focus-ring: rgba(52, 152, 219, 0.12);
|
||||||
|
--input-text: #374151;
|
||||||
|
--input-placeholder: #9ca3af;
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
--page-btn-bg: #ffffff;
|
||||||
|
--page-btn-border: #d1d5db;
|
||||||
|
--page-btn-hover-bg: #f3f4f6;
|
||||||
|
--page-btn-hover-border: #9ca3af;
|
||||||
|
--page-btn-active-bg: #2c3e50;
|
||||||
|
--page-btn-active-border: #2c3e50;
|
||||||
|
--page-btn-active-color: #ffffff;
|
||||||
|
--page-btn-color: #374151;
|
||||||
|
|
||||||
|
/* Overlay */
|
||||||
|
--overlay-bg: rgba(255, 255, 255, 0.8);
|
||||||
|
--spinner-track: #eef0f3;
|
||||||
|
--spinner-accent: #3498db;
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
--scrollbar-thumb: rgba(0, 0, 0, 0.15);
|
||||||
|
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.25);
|
||||||
|
|
||||||
|
/* Status labels */
|
||||||
|
--status-active-bg: #dcfce7;
|
||||||
|
--status-active-color: #15803d;
|
||||||
|
--status-inactive-bg: #fee2e2;
|
||||||
|
--status-inactive-color: #b91c1c;
|
||||||
|
--status-pending-bg: #fef3c7;
|
||||||
|
--status-pending-color: #a16207;
|
||||||
|
--status-reserved-bg: #dbeafe;
|
||||||
|
--status-reserved-color: #1d4ed8;
|
||||||
|
--status-provisioning-bg: #fef3c7;
|
||||||
|
--status-provisioning-color: #a16207;
|
||||||
|
--status-neutral-bg: #f1f5f9;
|
||||||
|
--status-neutral-color: #64748b;
|
||||||
|
--status-neutral-dot: #d1d5db;
|
||||||
|
--status-neutral-dot-ring: rgba(209, 213, 219, 0.3);
|
||||||
|
|
||||||
|
/* Toggle */
|
||||||
|
--toggle-bg: rgba(0, 0, 0, 0.08);
|
||||||
|
--toggle-hover-bg: rgba(0, 0, 0, 0.12);
|
||||||
|
--toggle-color: #666666;
|
||||||
|
|
||||||
|
/* Error illustration */
|
||||||
|
--error-circle-outer: #fef2f2;
|
||||||
|
--error-circle-stroke: #fecaca;
|
||||||
|
--error-circle-inner: #fee2e2;
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
/* Surfaces */
|
||||||
|
--bg-base: #0e0e0e;
|
||||||
|
--bg-surface: #1a1a1a;
|
||||||
|
--bg-surface-alt: #141414;
|
||||||
|
--bg-elevated: #1c1c1c;
|
||||||
|
--bg-inset: #111111;
|
||||||
|
--bg-gradient-from: #12161e;
|
||||||
|
--bg-gradient-to: #181d28;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-default: #2a2a2a;
|
||||||
|
--border-subtle: #262626;
|
||||||
|
--border-strong: #333333;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-primary: #e0e0e0;
|
||||||
|
--text-secondary: #737373;
|
||||||
|
--text-muted: #737373;
|
||||||
|
--text-faint: #525252;
|
||||||
|
--text-inverse: #0e0e0e;
|
||||||
|
|
||||||
|
/* Nav */
|
||||||
|
--nav-hover-bg: rgba(255, 255, 255, 0.04);
|
||||||
|
--nav-active-bg: rgba(255, 255, 255, 0.06);
|
||||||
|
--nav-active-color: #ffffff;
|
||||||
|
--nav-active-border: #ffffff;
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
--header-bg: #1a1a1a;
|
||||||
|
--header-text: #e0e0e0;
|
||||||
|
--header-shadow: none;
|
||||||
|
--accent-bar-from: #1a2a44;
|
||||||
|
--accent-bar-to: #0e1b33;
|
||||||
|
--accent-bar-height: 0;
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
--card-bg: #141414;
|
||||||
|
--card-hover-bg: #1c1c1c;
|
||||||
|
--card-border: #262626;
|
||||||
|
--card-hover-border: #3d3d3d;
|
||||||
|
--card-hover-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
--card-active-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
--card-focus-ring: #a3a3a3;
|
||||||
|
--card-arrow-color: #a3a3a3;
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
--avatar-gradient-from: #333333;
|
||||||
|
--avatar-gradient-to: #262626;
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
--input-bg: #111111;
|
||||||
|
--input-focus-bg: #161616;
|
||||||
|
--input-border: #333333;
|
||||||
|
--input-focus-border: #525252;
|
||||||
|
--input-focus-ring: rgba(255, 255, 255, 0.04);
|
||||||
|
--input-text: #d4d4d4;
|
||||||
|
--input-placeholder: #525252;
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
--page-btn-bg: #1a1a1a;
|
||||||
|
--page-btn-border: #333333;
|
||||||
|
--page-btn-hover-bg: #262626;
|
||||||
|
--page-btn-hover-border: #404040;
|
||||||
|
--page-btn-active-bg: #ffffff;
|
||||||
|
--page-btn-active-border: #ffffff;
|
||||||
|
--page-btn-active-color: #0e0e0e;
|
||||||
|
--page-btn-color: #a3a3a3;
|
||||||
|
|
||||||
|
/* Overlay */
|
||||||
|
--overlay-bg: rgba(14, 14, 14, 0.85);
|
||||||
|
--spinner-track: #262626;
|
||||||
|
--spinner-accent: #a3a3a3;
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
--scrollbar-thumb: rgba(255, 255, 255, 0.15);
|
||||||
|
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.3);
|
||||||
|
|
||||||
|
/* Status labels */
|
||||||
|
--status-active-bg: rgba(34, 197, 94, 0.12);
|
||||||
|
--status-active-color: #4ade80;
|
||||||
|
--status-inactive-bg: rgba(239, 68, 68, 0.12);
|
||||||
|
--status-inactive-color: #f87171;
|
||||||
|
--status-pending-bg: rgba(245, 158, 11, 0.12);
|
||||||
|
--status-pending-color: #fbbf24;
|
||||||
|
--status-reserved-bg: rgba(59, 130, 246, 0.12);
|
||||||
|
--status-reserved-color: #60a5fa;
|
||||||
|
--status-provisioning-bg: rgba(245, 158, 11, 0.12);
|
||||||
|
--status-provisioning-color: #fbbf24;
|
||||||
|
--status-neutral-bg: rgba(255, 255, 255, 0.06);
|
||||||
|
--status-neutral-color: #737373;
|
||||||
|
--status-neutral-dot: #525252;
|
||||||
|
--status-neutral-dot-ring: rgba(82, 82, 82, 0.3);
|
||||||
|
|
||||||
|
/* Toggle */
|
||||||
|
--toggle-bg: rgba(255, 255, 255, 0.08);
|
||||||
|
--toggle-hover-bg: rgba(255, 255, 255, 0.12);
|
||||||
|
--toggle-color: #a3a3a3;
|
||||||
|
|
||||||
|
/* Error illustration */
|
||||||
|
--error-circle-outer: rgba(239, 68, 68, 0.08);
|
||||||
|
--error-circle-stroke: rgba(239, 68, 68, 0.2);
|
||||||
|
--error-circle-inner: rgba(239, 68, 68, 0.12);
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -9,3 +229,47 @@ body {
|
|||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Theme Toggle Button
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--toggle-bg);
|
||||||
|
color: var(--toggle-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
background: var(--toggle-hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.6) rotate(-90deg);
|
||||||
|
transition:
|
||||||
|
opacity 0.25s ease,
|
||||||
|
transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-icon.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) rotate(0deg);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,925 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Company Detail — Two-Pane Layout
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Page container */
|
||||||
|
.company-detail-page {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Left pane (1/4 width) ── */
|
||||||
|
.company-detail-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 0 0 25%;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--header-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Right pane (3/4 width) ── */
|
||||||
|
.company-detail-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--header-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pane header (reusable for both panes) ── */
|
||||||
|
.detail-pane-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-pane-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Back button ── */
|
||||||
|
.back-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Right Pane — Tab Bar
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
position: relative;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active::after {
|
||||||
|
background: var(--input-focus-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-count-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
margin-left: 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--status-neutral-bg);
|
||||||
|
color: var(--status-neutral-color);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active .tab-count-badge {
|
||||||
|
background: var(--input-focus-border);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pane body (reusable for both panes) ── */
|
||||||
|
.detail-pane-body {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Left Pane — Company Profile
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Profile header (avatar + name + status) */
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--avatar-gradient-from),
|
||||||
|
var(--avatar-gradient-to)
|
||||||
|
);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-initials {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-status {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-status.active {
|
||||||
|
background: var(--status-active-bg);
|
||||||
|
color: var(--status-active-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-status.inactive {
|
||||||
|
background: var(--status-inactive-bg);
|
||||||
|
color: var(--status-inactive-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-status.pending {
|
||||||
|
background: var(--status-pending-bg);
|
||||||
|
color: var(--status-pending-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-status.neutral {
|
||||||
|
background: var(--status-neutral-bg);
|
||||||
|
color: var(--status-neutral-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info rows */
|
||||||
|
.profile-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:hover {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value.address-multiline {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.profile-empty {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Tab Content — Shared
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tab-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 64px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-empty p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-empty-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Tab Content — Configurations
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Split container */
|
||||||
|
.config-split {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-split.expanded {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
transition: flex 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-list.collapsed {
|
||||||
|
flex: 0 0 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s,
|
||||||
|
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
gap 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-list.collapsed .config-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item:hover {
|
||||||
|
border-color: var(--card-hover-border);
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item.selected {
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
background: var(--card-hover-bg);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-name-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status-dot.dot-active {
|
||||||
|
background: var(--status-active-color);
|
||||||
|
box-shadow: 0 0 0 2px var(--status-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status-dot.dot-inactive {
|
||||||
|
background: var(--status-inactive-color);
|
||||||
|
box-shadow: 0 0 0 2px var(--status-inactive-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status-dot.dot-reserved {
|
||||||
|
background: var(--status-reserved-color);
|
||||||
|
box-shadow: 0 0 0 2px var(--status-reserved-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status-dot.dot-pending {
|
||||||
|
background: var(--status-pending-color);
|
||||||
|
box-shadow: 0 0 0 2px var(--status-pending-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status-dot.dot-neutral {
|
||||||
|
background: var(--status-neutral-dot);
|
||||||
|
box-shadow: 0 0 0 2px var(--status-neutral-dot-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config header badges wrapper */
|
||||||
|
.config-header-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config status badge (in list items) */
|
||||||
|
.config-status-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status-badge.status-active {
|
||||||
|
background: var(--status-active-bg);
|
||||||
|
color: var(--status-active-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status-badge.status-inactive {
|
||||||
|
background: var(--status-inactive-bg);
|
||||||
|
color: var(--status-inactive-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status-badge.status-reserved {
|
||||||
|
background: var(--status-reserved-bg);
|
||||||
|
color: var(--status-reserved-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status-badge.status-pending {
|
||||||
|
background: var(--status-pending-bg);
|
||||||
|
color: var(--status-pending-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-status-badge.status-neutral {
|
||||||
|
background: var(--status-neutral-bg);
|
||||||
|
color: var(--status-neutral-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item.config-inactive {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item.config-inactive:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item.config-inactive.selected {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-list.collapsed .config-name {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-type-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--status-neutral-bg);
|
||||||
|
color: var(--status-neutral-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-kv {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-key {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-key::after {
|
||||||
|
content: ":";
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Config Detail Panel (right side)
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.config-detail-panel {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade-in animation via {#key} */
|
||||||
|
.config-detail-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: configFadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes configFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-detail-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-detail-close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-detail-close:hover {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-detail-json {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Config Detail — Structured Questions View
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Header with badges */
|
||||||
|
.config-detail-header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-detail-meta-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-badge.type {
|
||||||
|
background: var(--status-neutral-bg);
|
||||||
|
color: var(--status-neutral-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-badge.active-badge,
|
||||||
|
.config-badge.status-active {
|
||||||
|
background: var(--status-active-bg);
|
||||||
|
color: var(--status-active-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-badge.inactive-badge,
|
||||||
|
.config-badge.status-inactive {
|
||||||
|
background: var(--status-inactive-bg);
|
||||||
|
color: var(--status-inactive-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-badge.status-reserved {
|
||||||
|
background: var(--status-reserved-bg);
|
||||||
|
color: var(--status-reserved-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-badge.status-pending {
|
||||||
|
background: var(--status-pending-bg);
|
||||||
|
color: var(--status-pending-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-badge.status-neutral {
|
||||||
|
background: var(--status-neutral-bg);
|
||||||
|
color: var(--status-neutral-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Serial number */
|
||||||
|
.config-serial {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-serial-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-serial-value {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notes */
|
||||||
|
.config-notes {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-notes-text {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section title */
|
||||||
|
.config-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section-title svg {
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Questions grid */
|
||||||
|
.config-questions {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questions-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-row:hover {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-value-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-row:not(.has-answer) .question-value {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-value.textarea-value {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-row:hover .question-value.textarea-value {
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-value.password-value {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password toggle */
|
||||||
|
.password-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle:hover {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No questions placeholder */
|
||||||
|
.config-no-questions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 40px 16px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 13px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-no-questions p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer metadata */
|
||||||
|
.config-info-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-info-item svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-info-item strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
@@ -18,11 +18,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background: #ffffff;
|
background: var(--bg-surface);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow:
|
box-shadow: var(--header-shadow);
|
||||||
0 4px 24px rgba(0, 0, 0, 0.08),
|
|
||||||
0 1px 4px rgba(0, 0, 0, 0.04);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +32,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 20px 24px 16px;
|
padding: 20px 24px 16px;
|
||||||
border-bottom: 1px solid #eef0f3;
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,13 +46,13 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2c3e50;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-count {
|
.result-count {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #8492a6;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
@@ -65,25 +63,25 @@
|
|||||||
.search-bar input {
|
.search-bar input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 9px 34px 9px 38px;
|
padding: 9px 34px 9px 38px;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid var(--input-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: #f9fafb;
|
background: var(--input-bg);
|
||||||
color: #374151;
|
color: var(--input-text);
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s,
|
border-color 0.2s,
|
||||||
box-shadow 0.2s;
|
box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar input::placeholder {
|
.search-bar input::placeholder {
|
||||||
color: #9ca3af;
|
color: var(--input-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar input:focus {
|
.search-bar input:focus {
|
||||||
border-color: #3498db;
|
border-color: var(--input-focus-border);
|
||||||
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.12);
|
box-shadow: 0 0 0 3px var(--input-focus-ring);
|
||||||
background: #fff;
|
background: var(--input-focus-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.search-icon {
|
||||||
@@ -93,7 +91,7 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
color: #9ca3af;
|
color: var(--text-faint);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +102,7 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #9ca3af;
|
color: var(--text-faint);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -117,8 +115,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-clear:hover {
|
.search-clear:hover {
|
||||||
color: #374151;
|
color: var(--input-text);
|
||||||
background: #f3f4f6;
|
background: var(--nav-hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Pane body ── */
|
/* ── Pane body ── */
|
||||||
@@ -134,7 +132,7 @@
|
|||||||
.search-loading-overlay {
|
.search-loading-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(255, 255, 255, 0.8);
|
background: var(--overlay-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -146,8 +144,8 @@
|
|||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 4px solid #eef0f3;
|
border: 4px solid var(--spinner-track);
|
||||||
border-top-color: #3498db;
|
border-top-color: var(--spinner-accent);
|
||||||
animation: search-spin 0.7s linear infinite;
|
animation: search-spin 0.7s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,9 +182,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
background: #f8f9fb;
|
background: var(--card-bg);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid #eef0f3;
|
border: 1px solid var(--card-border);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
@@ -196,26 +194,25 @@
|
|||||||
background 0.18s;
|
background 0.18s;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: inherit;
|
color: var(--input-text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-card:hover {
|
.company-card:hover {
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
box-shadow:
|
box-shadow: var(--card-hover-shadow);
|
||||||
0 8px 24px rgba(0, 0, 0, 0.08),
|
border-color: var(--card-hover-border);
|
||||||
0 2px 8px rgba(0, 0, 0, 0.04);
|
background: var(--card-hover-bg);
|
||||||
border-color: #d0d9e8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-card:active {
|
.company-card:active {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
box-shadow: var(--card-active-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-card:focus-visible {
|
.company-card:focus-visible {
|
||||||
outline: 2px solid #3498db;
|
outline: 2px solid var(--card-focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +227,11 @@
|
|||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: linear-gradient(135deg, #3498db, #2980b9);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--avatar-gradient-from),
|
||||||
|
var(--avatar-gradient-to)
|
||||||
|
);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -270,8 +271,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.neutral {
|
.status-dot.neutral {
|
||||||
background: #d1d5db;
|
background: var(--status-neutral-dot);
|
||||||
box-shadow: 0 0 0 3px rgba(209, 213, 219, 0.3);
|
box-shadow: 0 0 0 3px var(--status-neutral-dot-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card body */
|
/* Card body */
|
||||||
@@ -286,7 +287,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1e293b;
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -295,7 +296,7 @@
|
|||||||
|
|
||||||
.card-email {
|
.card-email {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #94a3b8;
|
color: var(--text-secondary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -313,14 +314,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #64748b;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-icon {
|
.meta-icon {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #94a3b8;
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-item .mono {
|
.meta-item .mono {
|
||||||
@@ -336,7 +337,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
border-top: 1px solid #f1f5f9;
|
border-top: 1px solid var(--border-subtle);
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,28 +351,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-label.active {
|
.status-label.active {
|
||||||
background: #dcfce7;
|
background: var(--status-active-bg);
|
||||||
color: #15803d;
|
color: var(--status-active-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-label.inactive {
|
.status-label.inactive {
|
||||||
background: #fee2e2;
|
background: var(--status-inactive-bg);
|
||||||
color: #b91c1c;
|
color: var(--status-inactive-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-label.pending {
|
.status-label.pending {
|
||||||
background: #fef3c7;
|
background: var(--status-pending-bg);
|
||||||
color: #a16207;
|
color: var(--status-pending-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-label.neutral {
|
.status-label.neutral {
|
||||||
background: #f1f5f9;
|
background: var(--status-neutral-bg);
|
||||||
color: #64748b;
|
color: var(--status-neutral-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-date {
|
.card-date {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #94a3b8;
|
color: var(--text-faint);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +383,7 @@
|
|||||||
right: 16px;
|
right: 16px;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
color: #cbd5e1;
|
color: var(--card-arrow-color);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-4px);
|
transform: translateX(-4px);
|
||||||
transition:
|
transition:
|
||||||
@@ -405,14 +406,14 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-top: 1px solid #eef0f3;
|
border-top: 1px solid var(--border-subtle);
|
||||||
background: #f8f9fb;
|
background: var(--bg-surface-alt);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-info {
|
.page-info {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #8492a6;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
@@ -428,10 +429,10 @@
|
|||||||
min-width: 32px;
|
min-width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid var(--page-btn-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #fff;
|
background: var(--page-btn-bg);
|
||||||
color: #374151;
|
color: var(--page-btn-color);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -439,19 +440,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-btn:hover:not(:disabled):not(.active) {
|
.page-btn:hover:not(:disabled):not(.active) {
|
||||||
background: #f3f4f6;
|
background: var(--page-btn-hover-bg);
|
||||||
border-color: #9ca3af;
|
border-color: var(--page-btn-hover-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-btn:disabled {
|
.page-btn:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.3;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-btn.active {
|
.page-btn.active {
|
||||||
background: #3498db;
|
background: var(--page-btn-active-bg);
|
||||||
border-color: #3498db;
|
border-color: var(--page-btn-active-border);
|
||||||
color: #fff;
|
color: var(--page-btn-active-color);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +462,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
color: #9ca3af;
|
color: var(--text-faint);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -18,7 +18,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background: #ffffff;
|
background: var(--bg-surface);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 24px rgba(0, 0, 0, 0.08),
|
0 4px 24px rgba(0, 0, 0, 0.08),
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 20px 24px 16px;
|
padding: 20px 24px 16px;
|
||||||
border-bottom: 1px solid #eef0f3;
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2c3e50;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-status-badge {
|
.error-status-badge {
|
||||||
@@ -48,8 +48,8 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: #fee2e2;
|
background: var(--status-inactive-bg);
|
||||||
color: #b91c1c;
|
color: var(--status-inactive-color);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1e293b;
|
color: var(--text-primary);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
.error-message {
|
.error-message {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: #64748b;
|
color: var(--text-secondary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
.error-hint {
|
.error-hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #94a3b8;
|
color: var(--text-muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
@@ -139,14 +139,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: #f1f5f9;
|
background: var(--nav-hover-bg);
|
||||||
color: #475569;
|
color: var(--text-secondary);
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--border-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background: #e2e8f0;
|
background: var(--nav-active-bg);
|
||||||
border-color: #cbd5e1;
|
border-color: var(--border-strong);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-36
@@ -9,12 +9,12 @@
|
|||||||
/* Header */
|
/* Header */
|
||||||
.header {
|
.header {
|
||||||
height: 60px;
|
height: 60px;
|
||||||
background-color: #ffffff;
|
background: var(--header-bg);
|
||||||
color: #2c3e50;
|
color: var(--header-text);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--header-shadow);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
|
color: var(--header-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Layout Wrapper */
|
/* Layout Wrapper */
|
||||||
@@ -42,9 +43,9 @@
|
|||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
background-color: #ffffff;
|
background-color: var(--bg-surface-alt);
|
||||||
border-right: 1px solid #e0e0e0;
|
border-right: 1px solid var(--border-default);
|
||||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
|
box-shadow: none;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 14px 0;
|
padding: 14px 0;
|
||||||
color: #666666;
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -82,28 +83,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
background-color: #f0f4ff;
|
background-color: var(--nav-hover-bg);
|
||||||
color: #2c3e50;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:active {
|
.nav-item:active {
|
||||||
background-color: #e0eaff;
|
background-color: var(--nav-active-bg);
|
||||||
color: #3498db;
|
color: var(--nav-active-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Active page indicator */
|
/* Active page indicator */
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
color: #3498db;
|
color: var(--nav-active-color);
|
||||||
background-color: #eef4ff;
|
background-color: var(--nav-active-bg);
|
||||||
border-left: 3px solid #3498db;
|
border-left: 3px solid var(--nav-active-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active .nav-icon {
|
.nav-item.active .nav-icon {
|
||||||
stroke: #3498db;
|
stroke: var(--nav-active-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active .nav-label {
|
.nav-item.active .nav-label {
|
||||||
color: #3498db;
|
color: var(--nav-active-color);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,13 +135,18 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg, #3498db, #2980b9) top / 100% 220px no-repeat,
|
linear-gradient(180deg, var(--bg-gradient-from), var(--bg-gradient-to))
|
||||||
#f0f0f0;
|
top / 100% 220px no-repeat,
|
||||||
|
var(--bg-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.accent-bar {
|
.accent-bar {
|
||||||
height: 0;
|
height: var(--accent-bar-height);
|
||||||
background: linear-gradient(135deg, #3498db, #2980b9);
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--accent-bar-from),
|
||||||
|
var(--accent-bar-to)
|
||||||
|
);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,13 +176,13 @@
|
|||||||
|
|
||||||
.sidebar::-webkit-scrollbar-thumb,
|
.sidebar::-webkit-scrollbar-thumb,
|
||||||
.main-content::-webkit-scrollbar-thumb {
|
.main-content::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: var(--scrollbar-thumb);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar::-webkit-scrollbar-thumb:hover,
|
.sidebar::-webkit-scrollbar-thumb:hover,
|
||||||
.main-content::-webkit-scrollbar-thumb:hover {
|
.main-content::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.4);
|
background: var(--scrollbar-thumb-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
@@ -185,10 +191,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 4px 20px;
|
padding: 4px 20px;
|
||||||
background-color: #ffffff;
|
background-color: var(--bg-surface);
|
||||||
border-top: 1px solid #e0e0e0;
|
border-top: 1px solid var(--border-default);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: var(--text-faint);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +202,7 @@
|
|||||||
.nav-divider {
|
.nav-divider {
|
||||||
width: calc(100% - 1.5rem);
|
width: calc(100% - 1.5rem);
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid #d4dae3;
|
border-top: 1px solid var(--border-default);
|
||||||
margin: 0.5rem auto;
|
margin: 0.5rem auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -215,7 +221,6 @@
|
|||||||
|
|
||||||
.layout-wrapper {
|
.layout-wrapper {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
/* Account for fixed bottom nav */
|
|
||||||
padding-bottom: 56px;
|
padding-bottom: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +232,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-top: 1px solid #e0e0e0;
|
border-top: 1px solid var(--border-default);
|
||||||
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--header-shadow);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
@@ -248,7 +253,7 @@
|
|||||||
|
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-bottom: 3px solid #3498db;
|
border-bottom: 3px solid var(--nav-active-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
@@ -267,12 +272,12 @@
|
|||||||
|
|
||||||
.content-area {
|
.content-area {
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg, #3498db, #2980b9) top / 100% 190px no-repeat,
|
linear-gradient(180deg, var(--bg-gradient-from), var(--bg-gradient-to))
|
||||||
#f0f0f0;
|
top / 100% 190px no-repeat,
|
||||||
|
var(--bg-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
/* Hide footer on mobile since bottom nav takes that space */
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +285,7 @@
|
|||||||
width: 1px;
|
width: 1px;
|
||||||
height: calc(100% - 1rem);
|
height: calc(100% - 1rem);
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-left: 1px solid #d4dae3;
|
border-left: 1px solid var(--border-default);
|
||||||
margin: auto 0;
|
margin: auto 0;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
@@ -304,8 +309,9 @@
|
|||||||
|
|
||||||
.content-area {
|
.content-area {
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg, #3498db, #2980b9) top / 100% 160px no-repeat,
|
linear-gradient(180deg, var(--bg-gradient-from), var(--bg-gradient-to))
|
||||||
#f0f0f0;
|
top / 100% 160px no-repeat,
|
||||||
|
var(--bg-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
|
|||||||
Reference in New Issue
Block a user