refactor: extract reusable UI components and shared utilities
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
/** Custom message. Defaults to a generic "contact your administrator" message. */
|
||||
export let message: string =
|
||||
"You don't have permission to view this page. Contact your administrator to request access.";
|
||||
/** Optional title override. Defaults to "Access Denied". */
|
||||
export let title: string = "Access Denied";
|
||||
</script>
|
||||
|
||||
<div class="access-denied-wrap">
|
||||
<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>{title}</h3>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.access-denied-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.access-denied-wrap svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--status-inactive-color, #ef4444);
|
||||
}
|
||||
|
||||
.access-denied-wrap h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.access-denied-wrap p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { credential } from "$lib/optima-api/modules/credentials";
|
||||
import ModalShell from "./ModalShell.svelte";
|
||||
import type {
|
||||
CredentialType,
|
||||
CredentialTypeField,
|
||||
@@ -179,38 +180,13 @@
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
handleClose();
|
||||
}
|
||||
|
||||
function handleBackdropKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") handleClose();
|
||||
}
|
||||
|
||||
$: if (isOpen) {
|
||||
loadCredentialTypes();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
on:click={handleBackdropClick}
|
||||
on:keydown={handleBackdropKeydown}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Create Credential"
|
||||
tabindex="-1"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-group">
|
||||
<ModalShell {isOpen} title="Create Credential" onClose={handleClose}>
|
||||
<svelte:fragment slot="icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -222,27 +198,7 @@
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<h2>Create Credential</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>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="modal-body">
|
||||
{#if submitError}
|
||||
@@ -282,12 +238,7 @@
|
||||
</label>
|
||||
{#if isLoadingTypes}
|
||||
<div class="loading-wrap">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
class="spin-icon"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" class="spin-icon">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
@@ -339,8 +290,7 @@
|
||||
<div class="fields-section-header">
|
||||
<span class="fields-label">Credential Fields</span>
|
||||
<span class="fields-badge">
|
||||
{selectedType.fields.length} field{selectedType.fields
|
||||
.length === 1
|
||||
{selectedType.fields.length} field{selectedType.fields.length === 1
|
||||
? ""
|
||||
: "s"}
|
||||
</span>
|
||||
@@ -404,8 +354,8 @@
|
||||
<div class="multi-cred-section">
|
||||
{#if (subCredentials[field.id] ?? []).length === 0}
|
||||
<p class="multi-cred-empty">
|
||||
No entries yet. Add an entry to define
|
||||
sub-credentials for this field.
|
||||
No entries yet. Add an entry to define sub-credentials
|
||||
for this field.
|
||||
</p>
|
||||
{:else}
|
||||
{#each subCredentials[field.id] as entry, entryIdx}
|
||||
@@ -418,10 +368,7 @@
|
||||
type="button"
|
||||
class="multi-cred-entry-remove"
|
||||
on:click={() =>
|
||||
removeSubCredentialEntry(
|
||||
field.id,
|
||||
entryIdx,
|
||||
)}
|
||||
removeSubCredentialEntry(field.id, entryIdx)}
|
||||
disabled={isSubmitting}
|
||||
aria-label="Remove entry"
|
||||
>
|
||||
@@ -444,10 +391,8 @@
|
||||
</div>
|
||||
<div class="multi-cred-entry-body">
|
||||
<div class="multi-cred-entry-field">
|
||||
<label
|
||||
for="sub-cred-name-{field.id}-{entryIdx}"
|
||||
>Entry Name <span class="req">*</span
|
||||
></label
|
||||
<label for="sub-cred-name-{field.id}-{entryIdx}"
|
||||
>Entry Name <span class="req">*</span></label
|
||||
>
|
||||
<input
|
||||
id="sub-cred-name-{field.id}-{entryIdx}"
|
||||
@@ -505,10 +450,7 @@
|
||||
type="button"
|
||||
class="multi-cred-add-btn"
|
||||
on:click={() =>
|
||||
addSubCredentialEntry(
|
||||
field.id,
|
||||
field.subFields ?? [],
|
||||
)}
|
||||
addSubCredentialEntry(field.id, field.subFields ?? [])}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<svg
|
||||
@@ -564,90 +506,9 @@
|
||||
{isSubmitting ? "Creating…" : "Create Credential"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</ModalShell>
|
||||
|
||||
<style>
|
||||
/* ── Backdrop ── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: backdropIn 0.12s ease;
|
||||
}
|
||||
|
||||
/* ── Modal shell ── */
|
||||
.modal {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.28);
|
||||
width: 92%;
|
||||
max-width: 540px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: modalIn 0.15s ease;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-title-group h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-title-group svg {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--card-hover-bg);
|
||||
border-color: var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Body ── */
|
||||
.modal-body {
|
||||
padding: 18px 22px;
|
||||
@@ -1053,25 +914,6 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Animations ── */
|
||||
@keyframes backdropIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
@@ -1080,38 +922,10 @@
|
||||
|
||||
/* ── Mobile fixes ── */
|
||||
@media (max-width: 768px) {
|
||||
.modal-backdrop {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 92vh;
|
||||
border-radius: 14px 14px 0 0;
|
||||
transform: none;
|
||||
animation: modalSheetIn 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
@keyframes modalSheetIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 14px 16px 12px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from "$app/forms";
|
||||
import ModalShell from "./ModalShell.svelte";
|
||||
import type {
|
||||
CredentialType,
|
||||
CredentialTypeField,
|
||||
@@ -224,37 +225,14 @@
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
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}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={isEditMode
|
||||
? "Edit Credential Type"
|
||||
: "Create Credential Type"}
|
||||
tabindex="-1"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-group">
|
||||
<ModalShell
|
||||
{isOpen}
|
||||
title={isEditMode ? "Edit Credential Type" : "Create Credential Type"}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<svelte:fragment slot="icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -266,35 +244,11 @@
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<h2>
|
||||
{isEditMode ? "Edit Credential Type" : "Create Credential Type"}
|
||||
</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>
|
||||
</svelte:fragment>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={isEditMode
|
||||
? "?/updateCredentialType"
|
||||
: "?/createCredentialType"}
|
||||
action={isEditMode ? "?/updateCredentialType" : "?/createCredentialType"}
|
||||
use:enhance={({ formData }) => {
|
||||
formData.set("fields", JSON.stringify(fields));
|
||||
isSubmitting = true;
|
||||
@@ -397,8 +351,8 @@
|
||||
|
||||
{#if fields.length === 0}
|
||||
<p class="fields-empty">
|
||||
No fields defined yet. Fields define the data a credential of
|
||||
this type will store.
|
||||
No fields defined yet. Fields define the data a credential of this
|
||||
type will store.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="fields-list">
|
||||
@@ -507,10 +461,7 @@
|
||||
</div>
|
||||
<div class="field-toggles">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={field.required}
|
||||
/>
|
||||
<input type="checkbox" bind:checked={field.required} />
|
||||
<span class="toggle-text">Required</span>
|
||||
</label>
|
||||
<label
|
||||
@@ -555,16 +506,15 @@
|
||||
|
||||
{#if !field.subFields || field.subFields.length === 0}
|
||||
<p class="sub-fields-empty">
|
||||
No sub-fields defined. Sub-fields define the
|
||||
structure of each entry in this multi-credential.
|
||||
No sub-fields defined. Sub-fields define the structure
|
||||
of each entry in this multi-credential.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="sub-fields-list">
|
||||
{#each field.subFields as subField, subIndex}
|
||||
<div class="sub-field-card">
|
||||
<div class="sub-field-card-header">
|
||||
<span class="field-number"
|
||||
>#{subIndex + 1}</span
|
||||
<span class="field-number">#{subIndex + 1}</span
|
||||
>
|
||||
<div class="field-card-actions">
|
||||
{#if subIndex > 0}
|
||||
@@ -592,11 +542,7 @@
|
||||
class="field-move-btn"
|
||||
aria-label="Move down"
|
||||
on:click={() =>
|
||||
moveSubField(
|
||||
index,
|
||||
subIndex,
|
||||
"down",
|
||||
)}
|
||||
moveSubField(index, subIndex, "down")}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -629,12 +575,7 @@
|
||||
y1="6"
|
||||
x2="6"
|
||||
y2="18"
|
||||
/><line
|
||||
x1="6"
|
||||
y1="6"
|
||||
x2="18"
|
||||
y2="18"
|
||||
/>
|
||||
/><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -677,8 +618,7 @@
|
||||
</div>
|
||||
<div class="field-id-row">
|
||||
<div class="form-group field-id-group">
|
||||
<label
|
||||
for="sub-field-id-{index}-{subIndex}"
|
||||
<label for="sub-field-id-{index}-{subIndex}"
|
||||
>ID <span class="req">*</span></label
|
||||
>
|
||||
<input
|
||||
@@ -687,10 +627,7 @@
|
||||
placeholder="auto-generated from name"
|
||||
bind:value={subField.id}
|
||||
on:input={() =>
|
||||
handleSubFieldIdInput(
|
||||
index,
|
||||
subIndex,
|
||||
)}
|
||||
handleSubFieldIdInput(index, subIndex)}
|
||||
class="field-id-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -701,8 +638,7 @@
|
||||
type="checkbox"
|
||||
bind:checked={subField.required}
|
||||
/>
|
||||
<span class="toggle-text">Required</span
|
||||
>
|
||||
<span class="toggle-text">Required</span>
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
@@ -787,85 +723,9 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</ModalShell>
|
||||
|
||||
<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;
|
||||
animation: backdropIn 0.12s ease;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.28);
|
||||
width: 92%;
|
||||
max-width: 580px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: modalIn 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.modal-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-title-group h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-title-group svg {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--card-hover-bg);
|
||||
border-color: var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1276,24 +1136,4 @@
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes backdropIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from "$app/forms";
|
||||
import ModalShell from "./ModalShell.svelte";
|
||||
import type {
|
||||
PermissionsCategorized,
|
||||
PermissionCategory,
|
||||
PermissionNode,
|
||||
} from "$lib/optima-api/modules/permissions";
|
||||
import {
|
||||
collectAllNodes,
|
||||
collectAllPermNodes,
|
||||
allSelectableStrings,
|
||||
} from "$lib/permissions";
|
||||
import type { Role } from "$lib/optima-api/modules/roles";
|
||||
|
||||
export let isOpen = false;
|
||||
@@ -15,43 +21,6 @@
|
||||
|
||||
$: isEditMode = roleToEdit !== null;
|
||||
|
||||
/** Recursively collect all permission node strings from a category tree,
|
||||
* including field-level permission strings attached to each node. */
|
||||
function collectAllNodes(cat: PermissionCategory): string[] {
|
||||
const nodes: string[] = [];
|
||||
for (const p of cat.permissions ?? []) {
|
||||
nodes.push(p.node);
|
||||
if (p.fieldLevelPermissions) nodes.push(...p.fieldLevelPermissions);
|
||||
}
|
||||
if (cat.subCategories) {
|
||||
for (const sub of Object.values(cat.subCategories)) {
|
||||
nodes.push(...collectAllNodes(sub as PermissionCategory));
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Recursively collect all PermissionNode objects from a category tree. */
|
||||
function collectAllPermNodes(cat: PermissionCategory): PermissionNode[] {
|
||||
const nodes = [...(cat.permissions ?? [])];
|
||||
if (cat.subCategories) {
|
||||
for (const sub of Object.values(cat.subCategories)) {
|
||||
nodes.push(...collectAllPermNodes(sub as PermissionCategory));
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Collect all selectable strings (node + fieldLevelPermissions) from PermissionNode[]. */
|
||||
function allSelectableStrings(perms: PermissionNode[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const p of perms) {
|
||||
out.push(p.node);
|
||||
if (p.fieldLevelPermissions) out.push(...p.fieldLevelPermissions);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
$: if (isOpen && roleToEdit) {
|
||||
title = roleToEdit.title;
|
||||
moniker = roleToEdit.moniker;
|
||||
@@ -197,35 +166,14 @@
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
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}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={isEditMode ? "Edit Role" : "Create Role"}
|
||||
tabindex="-1"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-group">
|
||||
<ModalShell
|
||||
{isOpen}
|
||||
title={isEditMode ? "Edit Role" : "Create Role"}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<svelte:fragment slot="icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -236,27 +184,7 @@
|
||||
>
|
||||
<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>
|
||||
</svelte:fragment>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
@@ -326,8 +254,7 @@
|
||||
<div class="form-group">
|
||||
<label for="role-moniker">
|
||||
Moniker <span class="req">*</span>
|
||||
{#if !isEditMode}<span class="label-hint">Auto-generated</span
|
||||
>{/if}
|
||||
{#if !isEditMode}<span class="label-hint">Auto-generated</span>{/if}
|
||||
</label>
|
||||
<input
|
||||
id="role-moniker"
|
||||
@@ -356,8 +283,7 @@
|
||||
{#each permissionEntries as [catKey, category] (catKey)}
|
||||
{@const catPerms = category.permissions ?? []}
|
||||
{@const allCatPermNodes = collectAllPermNodes(category)}
|
||||
{@const allCatSelectables =
|
||||
allSelectableStrings(allCatPermNodes)}
|
||||
{@const allCatSelectables = allSelectableStrings(allCatPermNodes)}
|
||||
{@const allSel =
|
||||
allCatSelectables.length > 0 &&
|
||||
allCatSelectables.every((s) => selectedPermissions.has(s))}
|
||||
@@ -389,8 +315,7 @@
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span class="cat-name">{category.name}</span>
|
||||
<span class="cat-count">{allCatSelectables.length}</span
|
||||
>
|
||||
<span class="cat-count">{allCatSelectables.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -468,8 +393,7 @@
|
||||
</div>
|
||||
<div class="perm-text">
|
||||
<code class="perm-node">{perm.node}</code>
|
||||
<span class="perm-desc">{perm.description}</span
|
||||
>
|
||||
<span class="perm-desc">{perm.description}</span>
|
||||
</div>
|
||||
{#if hasFields}
|
||||
<button
|
||||
@@ -551,12 +475,7 @@
|
||||
width="8"
|
||||
height="8"
|
||||
>
|
||||
<line
|
||||
x1="1.5"
|
||||
y1="5"
|
||||
x2="8.5"
|
||||
y2="5"
|
||||
/>
|
||||
<line x1="1.5" y1="5" x2="8.5" y2="5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -564,8 +483,7 @@
|
||||
</button>
|
||||
</div>
|
||||
{#each perm.fieldLevelPermissions ?? [] as fieldPerm (fieldPerm)}
|
||||
{@const fSel =
|
||||
selectedPermissions.has(fieldPerm)}
|
||||
{@const fSel = selectedPermissions.has(fieldPerm)}
|
||||
<label
|
||||
class="perm-row field-perm-row"
|
||||
class:perm-sel={fSel}
|
||||
@@ -574,8 +492,7 @@
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
checked={fSel}
|
||||
on:change={() =>
|
||||
togglePermission(fieldPerm)}
|
||||
on:change={() => togglePermission(fieldPerm)}
|
||||
/>
|
||||
<div class="cb" class:cb-checked={fSel}>
|
||||
{#if fSel}
|
||||
@@ -611,8 +528,7 @@
|
||||
{#each Object.entries(category.subCategories ?? {}) as [subKey, subCat] (subKey)}
|
||||
{@const subCategory = subCat}
|
||||
{@const subPerms = subCategory.permissions ?? []}
|
||||
{@const subAllNodes =
|
||||
collectAllPermNodes(subCategory)}
|
||||
{@const subAllNodes = collectAllPermNodes(subCategory)}
|
||||
{@const subAllSelectables =
|
||||
allSelectableStrings(subAllNodes)}
|
||||
{@const subAllSel =
|
||||
@@ -659,8 +575,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="cat-all-btn"
|
||||
on:click={() =>
|
||||
toggleAllInCategory(subAllNodes)}
|
||||
on:click={() => toggleAllInCategory(subAllNodes)}
|
||||
title={subAllSel
|
||||
? "Deselect all in subcategory"
|
||||
: "Select all in subcategory"}
|
||||
@@ -709,13 +624,8 @@
|
||||
perm.fieldLevelPermissions.length > 0}
|
||||
{@const fieldsExpanded =
|
||||
hasFields &&
|
||||
expandedCategories.has(
|
||||
`field:${perm.node}`,
|
||||
)}
|
||||
<label
|
||||
class="perm-row"
|
||||
class:perm-sel={sel}
|
||||
>
|
||||
expandedCategories.has(`field:${perm.node}`)}
|
||||
<label class="perm-row" class:perm-sel={sel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
@@ -733,16 +643,12 @@
|
||||
width="8"
|
||||
height="8"
|
||||
>
|
||||
<polyline
|
||||
points="1.5 5 4 8 8.5 2"
|
||||
/>
|
||||
<polyline points="1.5 5 4 8 8.5 2" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="perm-text">
|
||||
<code class="perm-node"
|
||||
>{perm.node}</code
|
||||
>
|
||||
<code class="perm-node">{perm.node}</code>
|
||||
<span class="perm-desc"
|
||||
>{perm.description}</span
|
||||
>
|
||||
@@ -752,9 +658,7 @@
|
||||
type="button"
|
||||
class="field-expand-btn"
|
||||
on:click|preventDefault|stopPropagation={() =>
|
||||
toggleCategory(
|
||||
`field:${perm.node}`,
|
||||
)}
|
||||
toggleCategory(`field:${perm.node}`)}
|
||||
title={fieldsExpanded
|
||||
? "Hide field permissions"
|
||||
: `Show ${perm.fieldLevelPermissions?.length} field permissions`}
|
||||
@@ -772,8 +676,7 @@
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span class="field-expand-count"
|
||||
>{perm.fieldLevelPermissions
|
||||
?.length} fields</span
|
||||
>{perm.fieldLevelPermissions?.length} fields</span
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -801,8 +704,7 @@
|
||||
class="cat-all-btn"
|
||||
on:click={() =>
|
||||
toggleFieldLevelPerms(
|
||||
perm.fieldLevelPermissions ??
|
||||
[],
|
||||
perm.fieldLevelPermissions ?? [],
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -858,10 +760,7 @@
|
||||
on:change={() =>
|
||||
togglePermission(fieldPerm)}
|
||||
/>
|
||||
<div
|
||||
class="cb"
|
||||
class:cb-checked={fSel}
|
||||
>
|
||||
<div class="cb" class:cb-checked={fSel}>
|
||||
{#if fSel}
|
||||
<svg
|
||||
viewBox="0 0 10 10"
|
||||
@@ -877,8 +776,7 @@
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<code
|
||||
class="perm-node field-perm-node"
|
||||
<code class="perm-node field-perm-node"
|
||||
>{fieldPerm}</code
|
||||
>
|
||||
</label>
|
||||
@@ -960,8 +858,7 @@
|
||||
placeholder="Add custom node… (e.g. my.custom.node)"
|
||||
bind:value={customPermNode}
|
||||
on:keydown={(e) =>
|
||||
e.key === "Enter" &&
|
||||
(e.preventDefault(), addCustomPermission())}
|
||||
e.key === "Enter" && (e.preventDefault(), addCustomPermission())}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1013,94 +910,9 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</ModalShell>
|
||||
|
||||
<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;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from "$app/forms";
|
||||
import type { SubmitFunction } from "@sveltejs/kit";
|
||||
|
||||
/** Controls visibility. */
|
||||
export let isOpen = false;
|
||||
/** Dialog heading, e.g. "Delete User". */
|
||||
export let title: string;
|
||||
/** The ID value sent as a hidden input named "id". */
|
||||
export let idValue: string;
|
||||
/** The form ?/action target, e.g. "?/deleteUser". */
|
||||
export let formAction: string;
|
||||
/** Label for the submit button, e.g. "Delete User". */
|
||||
export let confirmLabel: string = "Delete";
|
||||
/** Whether the deletion request is in flight. */
|
||||
export let isDeleting = false;
|
||||
/** Error message to display under the body. */
|
||||
export let error = "";
|
||||
/** Called when the user cancels or presses Escape. */
|
||||
export let onCancel: () => void = () => {};
|
||||
/**
|
||||
* Called by the SvelteKit enhance action when the form completes.
|
||||
* If omitted the default SvelteKit behaviour is used.
|
||||
*/
|
||||
export let handleEnhance: SubmitFunction | undefined = undefined;
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="confirm-backdrop"
|
||||
on:click={onCancel}
|
||||
on:keydown={(e) => e.key === "Escape" && onCancel()}
|
||||
>
|
||||
<div
|
||||
class="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
tabindex="-1"
|
||||
on:click|stopPropagation
|
||||
on:keydown|stopPropagation
|
||||
>
|
||||
<div class="confirm-icon-wrap">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
width="22"
|
||||
height="22"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 id="confirm-title" class="confirm-title">{title}</h3>
|
||||
|
||||
<!-- Body: use a slot so callers can insert custom warning content -->
|
||||
<p class="confirm-body">
|
||||
<slot />
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<p class="confirm-error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-cancel"
|
||||
on:click={onCancel}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action={formAction}
|
||||
use:enhance={handleEnhance ?? (() => {})}
|
||||
>
|
||||
<input type="hidden" name="id" value={idValue} />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-delete-confirm"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting…" : confirmLabel}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.confirm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
background: var(--surface-primary, #1e1e2e);
|
||||
border: 1px solid var(--border-primary, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 14px;
|
||||
padding: 28px 28px 24px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-icon-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ef4444;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.confirm-body {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirm-error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-delete-confirm {
|
||||
padding: 7px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: var(--surface-secondary, rgba(255, 255, 255, 0.06));
|
||||
border: 1px solid var(--border-primary, rgba(255, 255, 255, 0.08));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-cancel:hover:not(:disabled) {
|
||||
background: var(--surface-hover, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.btn-delete-confirm {
|
||||
background: #ef4444;
|
||||
border: 1px solid transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-delete-confirm:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-cancel:disabled,
|
||||
.btn-delete-confirm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,11 @@
|
||||
PermissionCategory,
|
||||
PermissionNode,
|
||||
} from "$lib/optima-api/modules/permissions";
|
||||
import {
|
||||
collectAllNodes,
|
||||
collectAllPermNodes,
|
||||
allSelectableStrings,
|
||||
} from "$lib/permissions";
|
||||
|
||||
type UserWithRoles = User & { roleDetails: Role[] };
|
||||
|
||||
@@ -39,43 +44,6 @@
|
||||
// Expanded category state for the hierarchical permission view
|
||||
let expandedCategories = new Set<string>();
|
||||
|
||||
/** Recursively collect all permission node strings from a category tree,
|
||||
* including field-level permission strings attached to each node. */
|
||||
function collectAllNodes(cat: PermissionCategory): string[] {
|
||||
const nodes: string[] = [];
|
||||
for (const p of cat.permissions ?? []) {
|
||||
nodes.push(p.node);
|
||||
if (p.fieldLevelPermissions) nodes.push(...p.fieldLevelPermissions);
|
||||
}
|
||||
if (cat.subCategories) {
|
||||
for (const sub of Object.values(cat.subCategories)) {
|
||||
nodes.push(...collectAllNodes(sub as PermissionCategory));
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Recursively collect all PermissionNode objects from a category tree. */
|
||||
function collectAllPermNodes(cat: PermissionCategory): PermissionNode[] {
|
||||
const nodes = [...(cat.permissions ?? [])];
|
||||
if (cat.subCategories) {
|
||||
for (const sub of Object.values(cat.subCategories)) {
|
||||
nodes.push(...collectAllPermNodes(sub as PermissionCategory));
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Collect all selectable strings (node + fieldLevelPermissions) from PermissionNode[]. */
|
||||
function allSelectableStrings(perms: PermissionNode[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const p of perms) {
|
||||
out.push(p.node);
|
||||
if (p.fieldLevelPermissions) out.push(...p.fieldLevelPermissions);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
$: allPermissionNodeStrings = Object.values(permissionNodes ?? {}).flatMap(
|
||||
(cat) => collectAllNodes(cat as PermissionCategory),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
/** Controls visibility. */
|
||||
export let isOpen = false;
|
||||
/** Modal heading text. */
|
||||
export let title: string;
|
||||
/** aria-label for the dialog element. Defaults to `title`. */
|
||||
export let ariaLabel: string = "";
|
||||
/** Called when the backdrop is clicked or Escape is pressed. */
|
||||
export let onClose: () => void = () => {};
|
||||
|
||||
function handleBackdropClick() {
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleBackdropKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
on:click={handleBackdropClick}
|
||||
on:keydown={handleBackdropKeydown}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel || title}
|
||||
tabindex="-1"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-group">
|
||||
<slot name="icon" />
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="close-btn"
|
||||
on:click={onClose}
|
||||
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>
|
||||
|
||||
<slot />
|
||||
|
||||
{#if $$slots.footer}
|
||||
<div class="modal-footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 900;
|
||||
backdrop-filter: blur(3px);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface-primary, #1e1e2e);
|
||||
border: 1px solid var(--border-primary, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 620px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--border-primary, rgba(255, 255, 255, 0.06));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-title-group :global(svg) {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title-group h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary, rgba(255, 255, 255, 0.4));
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition:
|
||||
color 0.15s,
|
||||
background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-hover, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border-primary, rgba(255, 255, 255, 0.06));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { getPageNumbers } from "$lib/utils";
|
||||
|
||||
export let currentPage: number;
|
||||
export let totalPages: number;
|
||||
/** Called with the target page number when the user clicks a page control. */
|
||||
export let onNavigate: (page: number) => void;
|
||||
|
||||
$: pageNumbers = getPageNumbers(currentPage, totalPages);
|
||||
</script>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
<button
|
||||
class="page-btn"
|
||||
disabled={currentPage <= 1}
|
||||
on:click={() => onNavigate(currentPage - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#each pageNumbers as p}
|
||||
{#if p === "..."}
|
||||
<span class="page-ellipsis">…</span>
|
||||
{:else}
|
||||
<button
|
||||
class="page-btn"
|
||||
class:active={p === currentPage}
|
||||
on:click={() => onNavigate(p as number)}
|
||||
aria-current={p === currentPage ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="page-btn"
|
||||
disabled={currentPage >= totalPages}
|
||||
on:click={() => onNavigate(currentPage + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: var(--surface-hover, rgba(255, 255, 255, 0.06));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-btn.active {
|
||||
background: var(--accent-primary, #3b82f6);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-ellipsis {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
color: var(--text-tertiary, rgba(255, 255, 255, 0.3));
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Svelte action: positions a dropdown element using fixed coordinates so it
|
||||
* escapes `overflow: hidden` parent containers.
|
||||
*
|
||||
* Expects the dropdown's parent to contain a sibling element with class `menu-btn`.
|
||||
*
|
||||
* Usage:
|
||||
* ```svelte
|
||||
* <div class="my-dropdown" use:positionMenu>…</div>
|
||||
* ```
|
||||
*/
|
||||
export function positionMenu(node: HTMLElement) {
|
||||
const btn = node.parentElement?.querySelector(
|
||||
".menu-btn",
|
||||
) as HTMLElement | null;
|
||||
if (!btn) return;
|
||||
|
||||
function update() {
|
||||
const rect = btn!.getBoundingClientRect();
|
||||
node.style.top = `${rect.bottom + 4}px`;
|
||||
node.style.left = `${rect.right - node.offsetWidth}px`;
|
||||
}
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", update, true);
|
||||
window.addEventListener("resize", update);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
window.removeEventListener("scroll", update, true);
|
||||
window.removeEventListener("resize", update);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { optima } from "$lib";
|
||||
import type {
|
||||
PermissionCategory,
|
||||
PermissionNode,
|
||||
} from "$lib/optima-api/modules/permissions";
|
||||
|
||||
export type PermissionMap = Record<string, boolean> & {
|
||||
/** Set to `true` when the permission check itself failed (API error, timeout, etc.) */
|
||||
@@ -73,3 +77,51 @@ export const resolvePermissions = checkPermissions;
|
||||
export function hasPermission(map: PermissionMap, permission: string): boolean {
|
||||
return map[permission] === true;
|
||||
}
|
||||
|
||||
// ── Permission tree traversal helpers ────────────────────────────────────────
|
||||
// Shared by CreateRoleModal, EditUserModal, and any future component that
|
||||
// needs to walk the categorised permission tree returned by the API.
|
||||
|
||||
/**
|
||||
* Recursively collect all permission node strings from a category tree,
|
||||
* including field-level permission strings attached to each node.
|
||||
*/
|
||||
export function collectAllNodes(cat: PermissionCategory): string[] {
|
||||
const nodes: string[] = [];
|
||||
for (const p of cat.permissions ?? []) {
|
||||
nodes.push(p.node);
|
||||
if (p.fieldLevelPermissions) nodes.push(...p.fieldLevelPermissions);
|
||||
}
|
||||
if (cat.subCategories) {
|
||||
for (const sub of Object.values(cat.subCategories)) {
|
||||
nodes.push(...collectAllNodes(sub as PermissionCategory));
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all `PermissionNode` objects from a category tree.
|
||||
*/
|
||||
export function collectAllPermNodes(cat: PermissionCategory): PermissionNode[] {
|
||||
const nodes = [...(cat.permissions ?? [])];
|
||||
if (cat.subCategories) {
|
||||
for (const sub of Object.values(cat.subCategories)) {
|
||||
nodes.push(...collectAllPermNodes(sub as PermissionCategory));
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all selectable strings (node + fieldLevelPermissions) from a flat
|
||||
* array of `PermissionNode` objects.
|
||||
*/
|
||||
export function allSelectableStrings(perms: PermissionNode[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const p of perms) {
|
||||
out.push(p.node);
|
||||
if (p.fieldLevelPermissions) out.push(...p.fieldLevelPermissions);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Format an ISO date string into a human-readable locale date.
|
||||
* Returns an empty string (or "—" when `dash` is true) for missing/invalid values.
|
||||
*/
|
||||
export function formatDate(
|
||||
dateStr?: string | null,
|
||||
opts: { dash?: boolean } = { dash: true },
|
||||
): string {
|
||||
const fallback = opts.dash ? "—" : "";
|
||||
if (!dateStr) return fallback;
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a list of page numbers (with "..." ellipsis) for a pagination control.
|
||||
* Always includes page 1 and the last page; collapses distant pages into ellipsis.
|
||||
*/
|
||||
export function getPageNumbers(
|
||||
current: number,
|
||||
total: number,
|
||||
): (number | "...")[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
|
||||
const pages: (number | "...")[] = [1];
|
||||
if (current > 3) pages.push("...");
|
||||
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(total - 1, current + 1);
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
|
||||
if (current < total - 2) pages.push("...");
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from "$app/forms";
|
||||
import type { SubmitFunction } from "@sveltejs/kit";
|
||||
import type { PermissionMap } from "$lib/permissions";
|
||||
import type {
|
||||
CredentialType,
|
||||
CredentialTypeField,
|
||||
} from "$lib/optima-api/modules/credentialTypes";
|
||||
import { formatDate } from "$lib/utils";
|
||||
import { positionMenu } from "$lib/actions";
|
||||
import CreateCredentialTypeModal from "../../../components/CreateCredentialTypeModal.svelte";
|
||||
import AccessDenied from "../../../components/AccessDenied.svelte";
|
||||
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
|
||||
import "../../../styles/admin/credential-types.css";
|
||||
|
||||
export let data: {
|
||||
@@ -50,31 +53,6 @@
|
||||
openMenuId = openMenuId === id ? null : id;
|
||||
}
|
||||
|
||||
/** Position the dropdown using fixed coordinates so it escapes overflow:hidden parents */
|
||||
function positionMenu(node: HTMLElement) {
|
||||
const btn = node.parentElement?.querySelector(
|
||||
".menu-btn",
|
||||
) as HTMLElement | null;
|
||||
if (!btn) return;
|
||||
|
||||
function update() {
|
||||
const rect = btn!.getBoundingClientRect();
|
||||
node.style.top = `${rect.bottom + 4}px`;
|
||||
node.style.left = `${rect.right - node.offsetWidth}px`;
|
||||
}
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", update, true);
|
||||
window.addEventListener("resize", update);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
window.removeEventListener("scroll", update, true);
|
||||
window.removeEventListener("resize", update);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Delete confirmation
|
||||
let typeToDelete: CredentialType | null = null;
|
||||
let isDeleting = false;
|
||||
@@ -113,19 +91,6 @@
|
||||
expandedTypeId = expandedTypeId === id ? null : id;
|
||||
}
|
||||
|
||||
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 valueTypeLabel(vt: string): string {
|
||||
return vt
|
||||
.split("_")
|
||||
@@ -137,17 +102,9 @@
|
||||
<svelte:window on:click={() => (openMenuId = null)} />
|
||||
|
||||
{#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>
|
||||
<AccessDenied
|
||||
message="You don't have permission to manage credential types. Contact your administrator to request access."
|
||||
/>
|
||||
{:else if credentialTypes.length === 0}
|
||||
<CreateCredentialTypeModal
|
||||
isOpen={isCreateModalOpen}
|
||||
@@ -208,78 +165,24 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if typeToDelete}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="confirm-backdrop"
|
||||
on:click={cancelDelete}
|
||||
on:keydown={(e) => e.key === "Escape" && cancelDelete()}
|
||||
<DeleteConfirmDialog
|
||||
isOpen={!!typeToDelete}
|
||||
title="Delete Credential Type"
|
||||
idValue={typeToDelete?.id ?? ""}
|
||||
formAction="?/deleteCredentialType"
|
||||
confirmLabel="Delete"
|
||||
{isDeleting}
|
||||
error={deleteError}
|
||||
onCancel={cancelDelete}
|
||||
handleEnhance={handleDeleteEnhance}
|
||||
>
|
||||
<div
|
||||
class="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
tabindex="-1"
|
||||
on:click|stopPropagation
|
||||
on:keydown|stopPropagation
|
||||
>
|
||||
<div class="confirm-icon-wrap">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
width="22"
|
||||
height="22"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 id="confirm-title" class="confirm-title">Delete Credential Type</h3>
|
||||
<p class="confirm-body">
|
||||
Are you sure you want to delete
|
||||
<strong>{typeToDelete.name}</strong>?
|
||||
{#if typeToDelete.credentialCount > 0}
|
||||
Are you sure you want to delete <strong>{typeToDelete?.name}</strong>?
|
||||
{#if typeToDelete && typeToDelete.credentialCount > 0}
|
||||
This type has <strong>{typeToDelete.credentialCount}</strong>
|
||||
credential{typeToDelete.credentialCount === 1 ? "" : "s"} associated
|
||||
with it.
|
||||
credential{typeToDelete.credentialCount === 1 ? "" : "s"} associated with it.
|
||||
{/if}
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
{#if deleteError}
|
||||
<p class="confirm-error">{deleteError}</p>
|
||||
{/if}
|
||||
<div class="confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-cancel"
|
||||
on:click={cancelDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteCredentialType"
|
||||
use:enhance={handleDeleteEnhance}
|
||||
>
|
||||
<input type="hidden" name="id" value={typeToDelete.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-delete-confirm"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</DeleteConfirmDialog>
|
||||
|
||||
<div class="admin-table-header">
|
||||
<h3>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from "$app/forms";
|
||||
import type { SubmitFunction } from "@sveltejs/kit";
|
||||
import type { PermissionMap } from "$lib/permissions";
|
||||
import type { Role } from "$lib/optima-api/modules/roles";
|
||||
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
|
||||
import { formatDate } from "$lib/utils";
|
||||
import { positionMenu } from "$lib/actions";
|
||||
import CreateRoleModal from "../../../components/CreateRoleModal.svelte";
|
||||
import AccessDenied from "../../../components/AccessDenied.svelte";
|
||||
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
|
||||
import "../../../styles/admin/roles.css";
|
||||
|
||||
interface RoleUser {
|
||||
@@ -47,31 +50,6 @@
|
||||
openMenuId = openMenuId === id ? null : id;
|
||||
}
|
||||
|
||||
/** Position the dropdown using fixed coordinates so it escapes overflow:hidden parents */
|
||||
function positionMenu(node: HTMLElement) {
|
||||
const btn = node.parentElement?.querySelector(
|
||||
".menu-btn",
|
||||
) as HTMLElement | null;
|
||||
if (!btn) return;
|
||||
|
||||
function update() {
|
||||
const rect = btn!.getBoundingClientRect();
|
||||
node.style.top = `${rect.bottom + 4}px`;
|
||||
node.style.left = `${rect.right - node.offsetWidth}px`;
|
||||
}
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", update, true);
|
||||
window.addEventListener("resize", update);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
window.removeEventListener("scroll", update, true);
|
||||
window.removeEventListener("resize", update);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Delete confirmation
|
||||
let roleToDelete: Role | null = null;
|
||||
let isDeleting = false;
|
||||
@@ -109,35 +87,14 @@
|
||||
function toggleRole(id: string) {
|
||||
expandedRoleId = expandedRoleId === id ? null : id;
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={() => (openMenuId = null)} />
|
||||
|
||||
{#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 roles. Contact your administrator to
|
||||
request access.
|
||||
</p>
|
||||
</div>
|
||||
<AccessDenied
|
||||
message="You don't have permission to manage roles. Contact your administrator to request access."
|
||||
/>
|
||||
{:else if roles.length === 0}
|
||||
<CreateRoleModal
|
||||
isOpen={isCreateModalOpen}
|
||||
@@ -196,72 +153,20 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if roleToDelete}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="confirm-backdrop"
|
||||
on:click={cancelDelete}
|
||||
on:keydown={(e) => e.key === "Escape" && cancelDelete()}
|
||||
<DeleteConfirmDialog
|
||||
isOpen={!!roleToDelete}
|
||||
title="Delete Role"
|
||||
idValue={roleToDelete?.id ?? ""}
|
||||
formAction="?/deleteRole"
|
||||
confirmLabel="Delete Role"
|
||||
{isDeleting}
|
||||
error={deleteError}
|
||||
onCancel={cancelDelete}
|
||||
handleEnhance={handleDeleteEnhance}
|
||||
>
|
||||
<div
|
||||
class="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
tabindex="-1"
|
||||
on:click|stopPropagation
|
||||
on:keydown|stopPropagation
|
||||
>
|
||||
<div class="confirm-icon-wrap">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
width="22"
|
||||
height="22"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 id="confirm-title" class="confirm-title">Delete Role</h3>
|
||||
<p class="confirm-body">
|
||||
Are you sure you want to delete
|
||||
<strong>{roleToDelete.title}</strong>? This action cannot be undone.
|
||||
</p>
|
||||
{#if deleteError}
|
||||
<p class="confirm-error">{deleteError}</p>
|
||||
{/if}
|
||||
<div class="confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-cancel"
|
||||
on:click={cancelDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteRole"
|
||||
use:enhance={handleDeleteEnhance}
|
||||
>
|
||||
<input type="hidden" name="id" value={roleToDelete.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-delete-confirm"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting…" : "Delete Role"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Are you sure you want to delete <strong>{roleToDelete?.title}</strong>? This
|
||||
action cannot be undone.
|
||||
</DeleteConfirmDialog>
|
||||
|
||||
<div class="admin-table-header">
|
||||
<h3>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from "$app/forms";
|
||||
import type { SubmitFunction } from "@sveltejs/kit";
|
||||
import type { PermissionMap } from "$lib/permissions";
|
||||
import type { User } from "$lib/optima-api/modules/users";
|
||||
import type { Role } from "$lib/optima-api/modules/roles";
|
||||
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
|
||||
import { formatDate } from "$lib/utils";
|
||||
import { positionMenu } from "$lib/actions";
|
||||
import EditUserModal from "../../../components/EditUserModal.svelte";
|
||||
import AccessDenied from "../../../components/AccessDenied.svelte";
|
||||
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
|
||||
import "../../../styles/admin/users.css";
|
||||
|
||||
type UserWithRoles = User & { roleDetails: Role[] };
|
||||
@@ -51,30 +54,6 @@
|
||||
openMenuId = openMenuId === id ? null : id;
|
||||
}
|
||||
|
||||
function positionMenu(node: HTMLElement) {
|
||||
const btn = node.parentElement?.querySelector(
|
||||
".menu-btn",
|
||||
) as HTMLElement | null;
|
||||
if (!btn) return;
|
||||
|
||||
function update() {
|
||||
const rect = btn!.getBoundingClientRect();
|
||||
node.style.top = `${rect.bottom + 4}px`;
|
||||
node.style.left = `${rect.right - node.offsetWidth}px`;
|
||||
}
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", update, true);
|
||||
window.addEventListener("resize", update);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
window.removeEventListener("scroll", update, true);
|
||||
window.removeEventListener("resize", update);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Edit modal state
|
||||
let editingUser: UserWithRoles | null = null;
|
||||
|
||||
@@ -126,35 +105,14 @@
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={() => (openMenuId = null)} />
|
||||
|
||||
{#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>
|
||||
<AccessDenied
|
||||
message="You don't have permission to manage users. Contact your administrator to request access."
|
||||
/>
|
||||
{:else if users.length === 0}
|
||||
<div class="admin-tab-empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -180,73 +138,21 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
{#if userToDelete}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="confirm-backdrop"
|
||||
on:click={cancelDelete}
|
||||
on:keydown={(e) => e.key === "Escape" && cancelDelete()}
|
||||
<!-- Delete confirmation dialog -->
|
||||
<DeleteConfirmDialog
|
||||
isOpen={!!userToDelete}
|
||||
title="Delete User"
|
||||
idValue={userToDelete?.id ?? ""}
|
||||
formAction="?/deleteUser"
|
||||
confirmLabel="Delete User"
|
||||
{isDeleting}
|
||||
error={deleteError}
|
||||
onCancel={cancelDelete}
|
||||
handleEnhance={handleDeleteEnhance}
|
||||
>
|
||||
<div
|
||||
class="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
tabindex="-1"
|
||||
on:click|stopPropagation
|
||||
on:keydown|stopPropagation
|
||||
>
|
||||
<div class="confirm-icon-wrap">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
width="22"
|
||||
height="22"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 id="confirm-title" class="confirm-title">Delete User</h3>
|
||||
<p class="confirm-body">
|
||||
Are you sure you want to delete
|
||||
<strong>{userToDelete.name}</strong>? This action cannot be undone.
|
||||
</p>
|
||||
{#if deleteError}
|
||||
<p class="confirm-error">{deleteError}</p>
|
||||
{/if}
|
||||
<div class="confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-cancel"
|
||||
on:click={cancelDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteUser"
|
||||
use:enhance={handleDeleteEnhance}
|
||||
>
|
||||
<input type="hidden" name="id" value={userToDelete.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-delete-confirm"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting…" : "Delete User"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Are you sure you want to delete <strong>{userToDelete?.name}</strong>? This
|
||||
action cannot be undone.
|
||||
</DeleteConfirmDialog>
|
||||
|
||||
<div class="admin-table-header">
|
||||
<h3>
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { afterNavigate } from "$app/navigation";
|
||||
import type { PermissionMap } from "$lib/permissions";
|
||||
import { formatDate } from "$lib/utils";
|
||||
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
||||
import AccessDenied from "../../components/AccessDenied.svelte";
|
||||
import Pagination from "../../components/Pagination.svelte";
|
||||
import "../../styles/companies/companylist.css";
|
||||
|
||||
export let data: {
|
||||
@@ -93,19 +96,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
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 statusClass(status?: string): string {
|
||||
if (!status) return "neutral";
|
||||
const s = status.toLowerCase();
|
||||
@@ -123,28 +113,6 @@
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
// Generate visible page numbers with ellipsis
|
||||
function getPageNumbers(current: number, total: number): (number | "...")[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
|
||||
const pages: (number | "...")[] = [];
|
||||
pages.push(1);
|
||||
|
||||
if (current > 3) pages.push("...");
|
||||
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(total - 1, current + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
|
||||
if (current < total - 2) pages.push("...");
|
||||
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
$: pageNumbers = getPageNumbers(currentPage, totalPages);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -152,17 +120,9 @@
|
||||
</svelte:head>
|
||||
|
||||
{#if !hasAccess}
|
||||
<div class="access-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 view Companies. Contact your administrator to
|
||||
request access.
|
||||
</p>
|
||||
</div>
|
||||
<AccessDenied
|
||||
message="You don't have permission to view Companies. Contact your administrator to request access."
|
||||
/>
|
||||
{:else}
|
||||
<div class="companies-page">
|
||||
<div class="companies-pane">
|
||||
@@ -343,58 +303,7 @@
|
||||
<span class="page-info">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
<button
|
||||
class="page-btn"
|
||||
disabled={currentPage <= 1}
|
||||
on:click={() => navigateToPage(currentPage - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#each pageNumbers as p}
|
||||
{#if p === "..."}
|
||||
<span class="page-ellipsis">…</span>
|
||||
{:else}
|
||||
<button
|
||||
class="page-btn"
|
||||
class:active={p === currentPage}
|
||||
on:click={() => navigateToPage(p)}
|
||||
aria-current={p === currentPage ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="page-btn"
|
||||
disabled={currentPage >= totalPages}
|
||||
on:click={() => navigateToPage(currentPage + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
<Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -402,33 +311,4 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.access-denied {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.access-denied svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--status-inactive-color);
|
||||
}
|
||||
|
||||
.access-denied h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.access-denied p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { fly } from "svelte/transition";
|
||||
import type { PermissionMap } from "$lib/permissions";
|
||||
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
|
||||
import AccessDenied from "../../../components/AccessDenied.svelte";
|
||||
import Pagination from "../../../components/Pagination.svelte";
|
||||
import { formatDate } from "$lib/utils";
|
||||
import "../../../styles/procurement/catalog.css";
|
||||
|
||||
type CatalogItem = {
|
||||
@@ -416,33 +419,6 @@
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
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 getPageNumbers(current: number, total: number): (number | "...")[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const pages: (number | "...")[] = [1];
|
||||
if (current > 3) pages.push("...");
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(total - 1, current + 1);
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
if (current < total - 2) pages.push("...");
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
$: pageNumbers = getPageNumbers(currentPage, totalPages);
|
||||
|
||||
$: hasInactiveStaged = (() => {
|
||||
for (const id of stagedAdds) {
|
||||
const item = linkSearchResults.find((r) => r.id === id);
|
||||
@@ -466,17 +442,9 @@
|
||||
</svelte:head>
|
||||
|
||||
{#if !hasAccess}
|
||||
<div class="catalog-access-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 view the Product Catalog. Contact your
|
||||
administrator to request access.
|
||||
</p>
|
||||
</div>
|
||||
<AccessDenied
|
||||
message="You don't have permission to view the Product Catalog. Contact your administrator to request access."
|
||||
/>
|
||||
{:else}
|
||||
<div class="catalog-page" class:panel-open={selectedItem}>
|
||||
<!-- Toolbar -->
|
||||
@@ -1481,93 +1449,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="catalog-footer">
|
||||
<span class="catalog-page-info">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<nav class="catalog-pagination" aria-label="Catalog pagination">
|
||||
<button
|
||||
class="catalog-page-btn"
|
||||
disabled={currentPage <= 1}
|
||||
on:click={() => navigateToPage(currentPage - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#each pageNumbers as p}
|
||||
{#if p === "..."}
|
||||
<span class="catalog-page-ellipsis">…</span>
|
||||
{:else}
|
||||
<button
|
||||
class="catalog-page-btn"
|
||||
class:active={p === currentPage}
|
||||
on:click={() => navigateToPage(p)}
|
||||
aria-current={p === currentPage ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="catalog-page-btn"
|
||||
disabled={currentPage >= totalPages}
|
||||
on:click={() => navigateToPage(currentPage + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
<Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.catalog-access-denied {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
.catalog-access-denied svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--status-inactive-color);
|
||||
}
|
||||
.catalog-access-denied h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.catalog-access-denied p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import type { PermissionMap } from "$lib/permissions";
|
||||
import type { OpportunityType } from "$lib/optima-api/modules/sales";
|
||||
import { formatDate } from "$lib/utils";
|
||||
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
||||
import CreateOpportunityModal from "../../components/CreateOpportunityModal.svelte";
|
||||
import AccessDenied from "../../components/AccessDenied.svelte";
|
||||
import Pagination from "../../components/Pagination.svelte";
|
||||
import "../../styles/sales/sales.css";
|
||||
|
||||
type SalesOpportunity = {
|
||||
@@ -184,19 +187,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string | null): string {
|
||||
if (!dateStr) return "—";
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(op: SalesOpportunity): string {
|
||||
if (op.closedFlag) return "Closed";
|
||||
const statusId = op.status?.id;
|
||||
@@ -306,20 +296,6 @@
|
||||
s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
|
||||
return s;
|
||||
}
|
||||
|
||||
function getPageNumbers(current: number, total: number): (number | "...")[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const pages: (number | "...")[] = [1];
|
||||
if (current > 3) pages.push("...");
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(total - 1, current + 1);
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
if (current < total - 2) pages.push("...");
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
$: pageNumbers = getPageNumbers(currentPage, totalPages);
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={handleFilterClickOutside} />
|
||||
@@ -329,17 +305,9 @@
|
||||
</svelte:head>
|
||||
|
||||
{#if !hasAccess}
|
||||
<div class="sales-access-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 view Sales opportunities. Contact your
|
||||
administrator to request access.
|
||||
</p>
|
||||
</div>
|
||||
<AccessDenied
|
||||
message="You don't have permission to view Sales opportunities. Contact your administrator to request access."
|
||||
/>
|
||||
{:else}
|
||||
<div class="sales-page">
|
||||
<div class="sales-pane">
|
||||
@@ -549,58 +517,7 @@
|
||||
<span class="sales-page-info">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<nav class="sales-pagination" aria-label="Sales pagination">
|
||||
<button
|
||||
class="sales-page-btn"
|
||||
disabled={currentPage <= 1}
|
||||
on:click={() => navigateToPage(currentPage - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#each pageNumbers as p}
|
||||
{#if p === "..."}
|
||||
<span class="sales-page-ellipsis">…</span>
|
||||
{:else}
|
||||
<button
|
||||
class="sales-page-btn"
|
||||
class:active={p === currentPage}
|
||||
on:click={() => navigateToPage(p)}
|
||||
aria-current={p === currentPage ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="sales-page-btn"
|
||||
disabled={currentPage >= totalPages}
|
||||
on:click={() => navigateToPage(currentPage + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
<Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -613,36 +530,6 @@
|
||||
/>
|
||||
|
||||
<style>
|
||||
.sales-access-denied {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sales-access-denied svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--status-inactive-color);
|
||||
}
|
||||
|
||||
.sales-access-denied h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sales-access-denied p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.sales-create-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import type { PermissionMap } from "$lib/permissions";
|
||||
import type { OpportunityType } from "$lib/optima-api/modules/sales";
|
||||
import { formatDate } from "$lib/utils";
|
||||
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
|
||||
import CreateOpportunityModal from "../../../components/CreateOpportunityModal.svelte";
|
||||
import AccessDenied from "../../../components/AccessDenied.svelte";
|
||||
import Pagination from "../../../components/Pagination.svelte";
|
||||
import "../../../styles/sales/sales.css";
|
||||
|
||||
type SalesOpportunity = {
|
||||
@@ -184,19 +187,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string | null): string {
|
||||
if (!dateStr) return "—";
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(op: SalesOpportunity): string {
|
||||
if (op.closedFlag) return "Closed";
|
||||
const statusId = op.status?.id;
|
||||
@@ -307,20 +297,6 @@
|
||||
s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
|
||||
return s;
|
||||
}
|
||||
|
||||
function getPageNumbers(current: number, total: number): (number | "...")[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const pages: (number | "...")[] = [1];
|
||||
if (current > 3) pages.push("...");
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(total - 1, current + 1);
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
if (current < total - 2) pages.push("...");
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
$: pageNumbers = getPageNumbers(currentPage, totalPages);
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={handleFilterClickOutside} />
|
||||
@@ -330,17 +306,9 @@
|
||||
</svelte:head>
|
||||
|
||||
{#if !hasAccess}
|
||||
<div class="sales-access-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 view Sales opportunities. Contact your
|
||||
administrator to request access.
|
||||
</p>
|
||||
</div>
|
||||
<AccessDenied
|
||||
message="You don't have permission to view Sales opportunities. Contact your administrator to request access."
|
||||
/>
|
||||
{:else}
|
||||
<div class="sales-page">
|
||||
<div class="sales-pane">
|
||||
@@ -550,58 +518,7 @@
|
||||
<span class="sales-page-info">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<nav class="sales-pagination" aria-label="Sales pagination">
|
||||
<button
|
||||
class="sales-page-btn"
|
||||
disabled={currentPage <= 1}
|
||||
on:click={() => navigateToPage(currentPage - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#each pageNumbers as p}
|
||||
{#if p === "..."}
|
||||
<span class="sales-page-ellipsis">…</span>
|
||||
{:else}
|
||||
<button
|
||||
class="sales-page-btn"
|
||||
class:active={p === currentPage}
|
||||
on:click={() => navigateToPage(p)}
|
||||
aria-current={p === currentPage ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="sales-page-btn"
|
||||
disabled={currentPage >= totalPages}
|
||||
on:click={() => navigateToPage(currentPage + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
<Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -614,36 +531,6 @@
|
||||
/>
|
||||
|
||||
<style>
|
||||
.sales-access-denied {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sales-access-denied svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--status-inactive-color);
|
||||
}
|
||||
|
||||
.sales-access-denied h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sales-access-denied p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.sales-create-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user