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">
|
<script lang="ts">
|
||||||
import { credential } from "$lib/optima-api/modules/credentials";
|
import { credential } from "$lib/optima-api/modules/credentials";
|
||||||
|
import ModalShell from "./ModalShell.svelte";
|
||||||
import type {
|
import type {
|
||||||
CredentialType,
|
CredentialType,
|
||||||
CredentialTypeField,
|
CredentialTypeField,
|
||||||
@@ -179,38 +180,13 @@
|
|||||||
isOpen = false;
|
isOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBackdropClick() {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBackdropKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") handleClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (isOpen) {
|
$: if (isOpen) {
|
||||||
loadCredentialTypes();
|
loadCredentialTypes();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
<ModalShell {isOpen} title="Create Credential" onClose={handleClose}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<svelte:fragment slot="icon">
|
||||||
<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">
|
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -222,27 +198,7 @@
|
|||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
</svg>
|
</svg>
|
||||||
<h2>Create Credential</h2>
|
</svelte:fragment>
|
||||||
</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>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
{#if submitError}
|
{#if submitError}
|
||||||
@@ -282,12 +238,7 @@
|
|||||||
</label>
|
</label>
|
||||||
{#if isLoadingTypes}
|
{#if isLoadingTypes}
|
||||||
<div class="loading-wrap">
|
<div class="loading-wrap">
|
||||||
<svg
|
<svg viewBox="0 0 24 24" width="14" height="14" class="spin-icon">
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
class="spin-icon"
|
|
||||||
>
|
|
||||||
<circle
|
<circle
|
||||||
cx="12"
|
cx="12"
|
||||||
cy="12"
|
cy="12"
|
||||||
@@ -339,8 +290,7 @@
|
|||||||
<div class="fields-section-header">
|
<div class="fields-section-header">
|
||||||
<span class="fields-label">Credential Fields</span>
|
<span class="fields-label">Credential Fields</span>
|
||||||
<span class="fields-badge">
|
<span class="fields-badge">
|
||||||
{selectedType.fields.length} field{selectedType.fields
|
{selectedType.fields.length} field{selectedType.fields.length === 1
|
||||||
.length === 1
|
|
||||||
? ""
|
? ""
|
||||||
: "s"}
|
: "s"}
|
||||||
</span>
|
</span>
|
||||||
@@ -404,8 +354,8 @@
|
|||||||
<div class="multi-cred-section">
|
<div class="multi-cred-section">
|
||||||
{#if (subCredentials[field.id] ?? []).length === 0}
|
{#if (subCredentials[field.id] ?? []).length === 0}
|
||||||
<p class="multi-cred-empty">
|
<p class="multi-cred-empty">
|
||||||
No entries yet. Add an entry to define
|
No entries yet. Add an entry to define sub-credentials
|
||||||
sub-credentials for this field.
|
for this field.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each subCredentials[field.id] as entry, entryIdx}
|
{#each subCredentials[field.id] as entry, entryIdx}
|
||||||
@@ -418,10 +368,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="multi-cred-entry-remove"
|
class="multi-cred-entry-remove"
|
||||||
on:click={() =>
|
on:click={() =>
|
||||||
removeSubCredentialEntry(
|
removeSubCredentialEntry(field.id, entryIdx)}
|
||||||
field.id,
|
|
||||||
entryIdx,
|
|
||||||
)}
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
aria-label="Remove entry"
|
aria-label="Remove entry"
|
||||||
>
|
>
|
||||||
@@ -444,10 +391,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="multi-cred-entry-body">
|
<div class="multi-cred-entry-body">
|
||||||
<div class="multi-cred-entry-field">
|
<div class="multi-cred-entry-field">
|
||||||
<label
|
<label for="sub-cred-name-{field.id}-{entryIdx}"
|
||||||
for="sub-cred-name-{field.id}-{entryIdx}"
|
>Entry Name <span class="req">*</span></label
|
||||||
>Entry Name <span class="req">*</span
|
|
||||||
></label
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="sub-cred-name-{field.id}-{entryIdx}"
|
id="sub-cred-name-{field.id}-{entryIdx}"
|
||||||
@@ -505,10 +450,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="multi-cred-add-btn"
|
class="multi-cred-add-btn"
|
||||||
on:click={() =>
|
on:click={() =>
|
||||||
addSubCredentialEntry(
|
addSubCredentialEntry(field.id, field.subFields ?? [])}
|
||||||
field.id,
|
|
||||||
field.subFields ?? [],
|
|
||||||
)}
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -564,90 +506,9 @@
|
|||||||
{isSubmitting ? "Creating…" : "Create Credential"}
|
{isSubmitting ? "Creating…" : "Create Credential"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalShell>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<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 ── */
|
/* ── Body ── */
|
||||||
.modal-body {
|
.modal-body {
|
||||||
padding: 18px 22px;
|
padding: 18px 22px;
|
||||||
@@ -1053,25 +914,6 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Animations ── */
|
|
||||||
@keyframes backdropIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes modalIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
@@ -1080,38 +922,10 @@
|
|||||||
|
|
||||||
/* ── Mobile fixes ── */
|
/* ── Mobile fixes ── */
|
||||||
@media (max-width: 768px) {
|
@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 {
|
.modal-body {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
padding: 14px 16px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from "$app/forms";
|
import { enhance } from "$app/forms";
|
||||||
|
import ModalShell from "./ModalShell.svelte";
|
||||||
import type {
|
import type {
|
||||||
CredentialType,
|
CredentialType,
|
||||||
CredentialTypeField,
|
CredentialTypeField,
|
||||||
@@ -224,37 +225,14 @@
|
|||||||
reset();
|
reset();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBackdropClick() {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBackdropKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") handleClose();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
<ModalShell
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
{isOpen}
|
||||||
<div
|
title={isEditMode ? "Edit Credential Type" : "Create Credential Type"}
|
||||||
class="modal-backdrop"
|
onClose={handleClose}
|
||||||
on:click={handleBackdropClick}
|
>
|
||||||
on:keydown={handleBackdropKeydown}
|
<svelte:fragment slot="icon">
|
||||||
>
|
|
||||||
<!-- 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">
|
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -266,35 +244,11 @@
|
|||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
</svg>
|
</svg>
|
||||||
<h2>
|
</svelte:fragment>
|
||||||
{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>
|
|
||||||
|
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={isEditMode
|
action={isEditMode ? "?/updateCredentialType" : "?/createCredentialType"}
|
||||||
? "?/updateCredentialType"
|
|
||||||
: "?/createCredentialType"}
|
|
||||||
use:enhance={({ formData }) => {
|
use:enhance={({ formData }) => {
|
||||||
formData.set("fields", JSON.stringify(fields));
|
formData.set("fields", JSON.stringify(fields));
|
||||||
isSubmitting = true;
|
isSubmitting = true;
|
||||||
@@ -397,8 +351,8 @@
|
|||||||
|
|
||||||
{#if fields.length === 0}
|
{#if fields.length === 0}
|
||||||
<p class="fields-empty">
|
<p class="fields-empty">
|
||||||
No fields defined yet. Fields define the data a credential of
|
No fields defined yet. Fields define the data a credential of this
|
||||||
this type will store.
|
type will store.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="fields-list">
|
<div class="fields-list">
|
||||||
@@ -507,10 +461,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field-toggles">
|
<div class="field-toggles">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input
|
<input type="checkbox" bind:checked={field.required} />
|
||||||
type="checkbox"
|
|
||||||
bind:checked={field.required}
|
|
||||||
/>
|
|
||||||
<span class="toggle-text">Required</span>
|
<span class="toggle-text">Required</span>
|
||||||
</label>
|
</label>
|
||||||
<label
|
<label
|
||||||
@@ -555,16 +506,15 @@
|
|||||||
|
|
||||||
{#if !field.subFields || field.subFields.length === 0}
|
{#if !field.subFields || field.subFields.length === 0}
|
||||||
<p class="sub-fields-empty">
|
<p class="sub-fields-empty">
|
||||||
No sub-fields defined. Sub-fields define the
|
No sub-fields defined. Sub-fields define the structure
|
||||||
structure of each entry in this multi-credential.
|
of each entry in this multi-credential.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="sub-fields-list">
|
<div class="sub-fields-list">
|
||||||
{#each field.subFields as subField, subIndex}
|
{#each field.subFields as subField, subIndex}
|
||||||
<div class="sub-field-card">
|
<div class="sub-field-card">
|
||||||
<div class="sub-field-card-header">
|
<div class="sub-field-card-header">
|
||||||
<span class="field-number"
|
<span class="field-number">#{subIndex + 1}</span
|
||||||
>#{subIndex + 1}</span
|
|
||||||
>
|
>
|
||||||
<div class="field-card-actions">
|
<div class="field-card-actions">
|
||||||
{#if subIndex > 0}
|
{#if subIndex > 0}
|
||||||
@@ -592,11 +542,7 @@
|
|||||||
class="field-move-btn"
|
class="field-move-btn"
|
||||||
aria-label="Move down"
|
aria-label="Move down"
|
||||||
on:click={() =>
|
on:click={() =>
|
||||||
moveSubField(
|
moveSubField(index, subIndex, "down")}
|
||||||
index,
|
|
||||||
subIndex,
|
|
||||||
"down",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -629,12 +575,7 @@
|
|||||||
y1="6"
|
y1="6"
|
||||||
x2="6"
|
x2="6"
|
||||||
y2="18"
|
y2="18"
|
||||||
/><line
|
/><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
x1="6"
|
|
||||||
y1="6"
|
|
||||||
x2="18"
|
|
||||||
y2="18"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -677,8 +618,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field-id-row">
|
<div class="field-id-row">
|
||||||
<div class="form-group field-id-group">
|
<div class="form-group field-id-group">
|
||||||
<label
|
<label for="sub-field-id-{index}-{subIndex}"
|
||||||
for="sub-field-id-{index}-{subIndex}"
|
|
||||||
>ID <span class="req">*</span></label
|
>ID <span class="req">*</span></label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -687,10 +627,7 @@
|
|||||||
placeholder="auto-generated from name"
|
placeholder="auto-generated from name"
|
||||||
bind:value={subField.id}
|
bind:value={subField.id}
|
||||||
on:input={() =>
|
on:input={() =>
|
||||||
handleSubFieldIdInput(
|
handleSubFieldIdInput(index, subIndex)}
|
||||||
index,
|
|
||||||
subIndex,
|
|
||||||
)}
|
|
||||||
class="field-id-input"
|
class="field-id-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -701,8 +638,7 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={subField.required}
|
bind:checked={subField.required}
|
||||||
/>
|
/>
|
||||||
<span class="toggle-text">Required</span
|
<span class="toggle-text">Required</span>
|
||||||
>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input
|
<input
|
||||||
@@ -787,85 +723,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</ModalShell>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<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 {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1276,24 +1136,4 @@
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from "$app/forms";
|
import { enhance } from "$app/forms";
|
||||||
|
import ModalShell from "./ModalShell.svelte";
|
||||||
import type {
|
import type {
|
||||||
PermissionsCategorized,
|
PermissionsCategorized,
|
||||||
PermissionCategory,
|
PermissionCategory,
|
||||||
PermissionNode,
|
PermissionNode,
|
||||||
} from "$lib/optima-api/modules/permissions";
|
} from "$lib/optima-api/modules/permissions";
|
||||||
|
import {
|
||||||
|
collectAllNodes,
|
||||||
|
collectAllPermNodes,
|
||||||
|
allSelectableStrings,
|
||||||
|
} from "$lib/permissions";
|
||||||
import type { Role } from "$lib/optima-api/modules/roles";
|
import type { Role } from "$lib/optima-api/modules/roles";
|
||||||
|
|
||||||
export let isOpen = false;
|
export let isOpen = false;
|
||||||
@@ -15,43 +21,6 @@
|
|||||||
|
|
||||||
$: isEditMode = roleToEdit !== null;
|
$: 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) {
|
$: if (isOpen && roleToEdit) {
|
||||||
title = roleToEdit.title;
|
title = roleToEdit.title;
|
||||||
moniker = roleToEdit.moniker;
|
moniker = roleToEdit.moniker;
|
||||||
@@ -197,35 +166,14 @@
|
|||||||
reset();
|
reset();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBackdropClick() {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBackdropKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") handleClose();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
<ModalShell
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
{isOpen}
|
||||||
<div
|
title={isEditMode ? "Edit Role" : "Create Role"}
|
||||||
class="modal-backdrop"
|
onClose={handleClose}
|
||||||
on:click={handleBackdropClick}
|
>
|
||||||
on:keydown={handleBackdropKeydown}
|
<svelte:fragment slot="icon">
|
||||||
>
|
|
||||||
<!-- 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">
|
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -236,27 +184,7 @@
|
|||||||
>
|
>
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||||
</svg>
|
</svg>
|
||||||
<h2>{isEditMode ? "Edit Role" : "Create Role"}</h2>
|
</svelte:fragment>
|
||||||
</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
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
@@ -326,8 +254,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="role-moniker">
|
<label for="role-moniker">
|
||||||
Moniker <span class="req">*</span>
|
Moniker <span class="req">*</span>
|
||||||
{#if !isEditMode}<span class="label-hint">Auto-generated</span
|
{#if !isEditMode}<span class="label-hint">Auto-generated</span>{/if}
|
||||||
>{/if}
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="role-moniker"
|
id="role-moniker"
|
||||||
@@ -356,8 +283,7 @@
|
|||||||
{#each permissionEntries as [catKey, category] (catKey)}
|
{#each permissionEntries as [catKey, category] (catKey)}
|
||||||
{@const catPerms = category.permissions ?? []}
|
{@const catPerms = category.permissions ?? []}
|
||||||
{@const allCatPermNodes = collectAllPermNodes(category)}
|
{@const allCatPermNodes = collectAllPermNodes(category)}
|
||||||
{@const allCatSelectables =
|
{@const allCatSelectables = allSelectableStrings(allCatPermNodes)}
|
||||||
allSelectableStrings(allCatPermNodes)}
|
|
||||||
{@const allSel =
|
{@const allSel =
|
||||||
allCatSelectables.length > 0 &&
|
allCatSelectables.length > 0 &&
|
||||||
allCatSelectables.every((s) => selectedPermissions.has(s))}
|
allCatSelectables.every((s) => selectedPermissions.has(s))}
|
||||||
@@ -389,8 +315,7 @@
|
|||||||
<polyline points="9 18 15 12 9 6" />
|
<polyline points="9 18 15 12 9 6" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="cat-name">{category.name}</span>
|
<span class="cat-name">{category.name}</span>
|
||||||
<span class="cat-count">{allCatSelectables.length}</span
|
<span class="cat-count">{allCatSelectables.length}</span>
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -468,8 +393,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="perm-text">
|
<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
|
<span class="perm-desc">{perm.description}</span>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{#if hasFields}
|
{#if hasFields}
|
||||||
<button
|
<button
|
||||||
@@ -551,12 +475,7 @@
|
|||||||
width="8"
|
width="8"
|
||||||
height="8"
|
height="8"
|
||||||
>
|
>
|
||||||
<line
|
<line x1="1.5" y1="5" x2="8.5" y2="5" />
|
||||||
x1="1.5"
|
|
||||||
y1="5"
|
|
||||||
x2="8.5"
|
|
||||||
y2="5"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -564,8 +483,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#each perm.fieldLevelPermissions ?? [] as fieldPerm (fieldPerm)}
|
{#each perm.fieldLevelPermissions ?? [] as fieldPerm (fieldPerm)}
|
||||||
{@const fSel =
|
{@const fSel = selectedPermissions.has(fieldPerm)}
|
||||||
selectedPermissions.has(fieldPerm)}
|
|
||||||
<label
|
<label
|
||||||
class="perm-row field-perm-row"
|
class="perm-row field-perm-row"
|
||||||
class:perm-sel={fSel}
|
class:perm-sel={fSel}
|
||||||
@@ -574,8 +492,7 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
checked={fSel}
|
checked={fSel}
|
||||||
on:change={() =>
|
on:change={() => togglePermission(fieldPerm)}
|
||||||
togglePermission(fieldPerm)}
|
|
||||||
/>
|
/>
|
||||||
<div class="cb" class:cb-checked={fSel}>
|
<div class="cb" class:cb-checked={fSel}>
|
||||||
{#if fSel}
|
{#if fSel}
|
||||||
@@ -611,8 +528,7 @@
|
|||||||
{#each Object.entries(category.subCategories ?? {}) as [subKey, subCat] (subKey)}
|
{#each Object.entries(category.subCategories ?? {}) as [subKey, subCat] (subKey)}
|
||||||
{@const subCategory = subCat}
|
{@const subCategory = subCat}
|
||||||
{@const subPerms = subCategory.permissions ?? []}
|
{@const subPerms = subCategory.permissions ?? []}
|
||||||
{@const subAllNodes =
|
{@const subAllNodes = collectAllPermNodes(subCategory)}
|
||||||
collectAllPermNodes(subCategory)}
|
|
||||||
{@const subAllSelectables =
|
{@const subAllSelectables =
|
||||||
allSelectableStrings(subAllNodes)}
|
allSelectableStrings(subAllNodes)}
|
||||||
{@const subAllSel =
|
{@const subAllSel =
|
||||||
@@ -659,8 +575,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="cat-all-btn"
|
class="cat-all-btn"
|
||||||
on:click={() =>
|
on:click={() => toggleAllInCategory(subAllNodes)}
|
||||||
toggleAllInCategory(subAllNodes)}
|
|
||||||
title={subAllSel
|
title={subAllSel
|
||||||
? "Deselect all in subcategory"
|
? "Deselect all in subcategory"
|
||||||
: "Select all in subcategory"}
|
: "Select all in subcategory"}
|
||||||
@@ -709,13 +624,8 @@
|
|||||||
perm.fieldLevelPermissions.length > 0}
|
perm.fieldLevelPermissions.length > 0}
|
||||||
{@const fieldsExpanded =
|
{@const fieldsExpanded =
|
||||||
hasFields &&
|
hasFields &&
|
||||||
expandedCategories.has(
|
expandedCategories.has(`field:${perm.node}`)}
|
||||||
`field:${perm.node}`,
|
<label class="perm-row" class:perm-sel={sel}>
|
||||||
)}
|
|
||||||
<label
|
|
||||||
class="perm-row"
|
|
||||||
class:perm-sel={sel}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
@@ -733,16 +643,12 @@
|
|||||||
width="8"
|
width="8"
|
||||||
height="8"
|
height="8"
|
||||||
>
|
>
|
||||||
<polyline
|
<polyline points="1.5 5 4 8 8.5 2" />
|
||||||
points="1.5 5 4 8 8.5 2"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="perm-text">
|
<div class="perm-text">
|
||||||
<code class="perm-node"
|
<code class="perm-node">{perm.node}</code>
|
||||||
>{perm.node}</code
|
|
||||||
>
|
|
||||||
<span class="perm-desc"
|
<span class="perm-desc"
|
||||||
>{perm.description}</span
|
>{perm.description}</span
|
||||||
>
|
>
|
||||||
@@ -752,9 +658,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="field-expand-btn"
|
class="field-expand-btn"
|
||||||
on:click|preventDefault|stopPropagation={() =>
|
on:click|preventDefault|stopPropagation={() =>
|
||||||
toggleCategory(
|
toggleCategory(`field:${perm.node}`)}
|
||||||
`field:${perm.node}`,
|
|
||||||
)}
|
|
||||||
title={fieldsExpanded
|
title={fieldsExpanded
|
||||||
? "Hide field permissions"
|
? "Hide field permissions"
|
||||||
: `Show ${perm.fieldLevelPermissions?.length} field permissions`}
|
: `Show ${perm.fieldLevelPermissions?.length} field permissions`}
|
||||||
@@ -772,8 +676,7 @@
|
|||||||
<polyline points="9 18 15 12 9 6" />
|
<polyline points="9 18 15 12 9 6" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="field-expand-count"
|
<span class="field-expand-count"
|
||||||
>{perm.fieldLevelPermissions
|
>{perm.fieldLevelPermissions?.length} fields</span
|
||||||
?.length} fields</span
|
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -801,8 +704,7 @@
|
|||||||
class="cat-all-btn"
|
class="cat-all-btn"
|
||||||
on:click={() =>
|
on:click={() =>
|
||||||
toggleFieldLevelPerms(
|
toggleFieldLevelPerms(
|
||||||
perm.fieldLevelPermissions ??
|
perm.fieldLevelPermissions ?? [],
|
||||||
[],
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -858,10 +760,7 @@
|
|||||||
on:change={() =>
|
on:change={() =>
|
||||||
togglePermission(fieldPerm)}
|
togglePermission(fieldPerm)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div class="cb" class:cb-checked={fSel}>
|
||||||
class="cb"
|
|
||||||
class:cb-checked={fSel}
|
|
||||||
>
|
|
||||||
{#if fSel}
|
{#if fSel}
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 10 10"
|
viewBox="0 0 10 10"
|
||||||
@@ -877,8 +776,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<code
|
<code class="perm-node field-perm-node"
|
||||||
class="perm-node field-perm-node"
|
|
||||||
>{fieldPerm}</code
|
>{fieldPerm}</code
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
@@ -960,8 +858,7 @@
|
|||||||
placeholder="Add custom node… (e.g. my.custom.node)"
|
placeholder="Add custom node… (e.g. my.custom.node)"
|
||||||
bind:value={customPermNode}
|
bind:value={customPermNode}
|
||||||
on:keydown={(e) =>
|
on:keydown={(e) =>
|
||||||
e.key === "Enter" &&
|
e.key === "Enter" && (e.preventDefault(), addCustomPermission())}
|
||||||
(e.preventDefault(), addCustomPermission())}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1013,94 +910,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</ModalShell>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<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 wraps body + footer so the submit btn works ── */
|
||||||
form {
|
form {
|
||||||
display: flex;
|
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,
|
PermissionCategory,
|
||||||
PermissionNode,
|
PermissionNode,
|
||||||
} from "$lib/optima-api/modules/permissions";
|
} from "$lib/optima-api/modules/permissions";
|
||||||
|
import {
|
||||||
|
collectAllNodes,
|
||||||
|
collectAllPermNodes,
|
||||||
|
allSelectableStrings,
|
||||||
|
} from "$lib/permissions";
|
||||||
|
|
||||||
type UserWithRoles = User & { roleDetails: Role[] };
|
type UserWithRoles = User & { roleDetails: Role[] };
|
||||||
|
|
||||||
@@ -39,43 +44,6 @@
|
|||||||
// Expanded category state for the hierarchical permission view
|
// Expanded category state for the hierarchical permission view
|
||||||
let expandedCategories = new Set<string>();
|
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(
|
$: allPermissionNodeStrings = Object.values(permissionNodes ?? {}).flatMap(
|
||||||
(cat) => collectAllNodes(cat as PermissionCategory),
|
(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 { optima } from "$lib";
|
||||||
|
import type {
|
||||||
|
PermissionCategory,
|
||||||
|
PermissionNode,
|
||||||
|
} from "$lib/optima-api/modules/permissions";
|
||||||
|
|
||||||
export type PermissionMap = Record<string, boolean> & {
|
export type PermissionMap = Record<string, boolean> & {
|
||||||
/** Set to `true` when the permission check itself failed (API error, timeout, etc.) */
|
/** 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 {
|
export function hasPermission(map: PermissionMap, permission: string): boolean {
|
||||||
return map[permission] === true;
|
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">
|
<script lang="ts">
|
||||||
import { enhance } from "$app/forms";
|
|
||||||
import type { SubmitFunction } from "@sveltejs/kit";
|
import type { SubmitFunction } from "@sveltejs/kit";
|
||||||
import type { PermissionMap } from "$lib/permissions";
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
import type {
|
import type {
|
||||||
CredentialType,
|
CredentialType,
|
||||||
CredentialTypeField,
|
CredentialTypeField,
|
||||||
} from "$lib/optima-api/modules/credentialTypes";
|
} from "$lib/optima-api/modules/credentialTypes";
|
||||||
|
import { formatDate } from "$lib/utils";
|
||||||
|
import { positionMenu } from "$lib/actions";
|
||||||
import CreateCredentialTypeModal from "../../../components/CreateCredentialTypeModal.svelte";
|
import CreateCredentialTypeModal from "../../../components/CreateCredentialTypeModal.svelte";
|
||||||
|
import AccessDenied from "../../../components/AccessDenied.svelte";
|
||||||
|
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
|
||||||
import "../../../styles/admin/credential-types.css";
|
import "../../../styles/admin/credential-types.css";
|
||||||
|
|
||||||
export let data: {
|
export let data: {
|
||||||
@@ -50,31 +53,6 @@
|
|||||||
openMenuId = openMenuId === id ? null : id;
|
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
|
// Delete confirmation
|
||||||
let typeToDelete: CredentialType | null = null;
|
let typeToDelete: CredentialType | null = null;
|
||||||
let isDeleting = false;
|
let isDeleting = false;
|
||||||
@@ -113,19 +91,6 @@
|
|||||||
expandedTypeId = expandedTypeId === id ? null : id;
|
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 {
|
function valueTypeLabel(vt: string): string {
|
||||||
return vt
|
return vt
|
||||||
.split("_")
|
.split("_")
|
||||||
@@ -137,17 +102,9 @@
|
|||||||
<svelte:window on:click={() => (openMenuId = null)} />
|
<svelte:window on:click={() => (openMenuId = null)} />
|
||||||
|
|
||||||
{#if !hasAccess}
|
{#if !hasAccess}
|
||||||
<div class="admin-denied">
|
<AccessDenied
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
message="You don't have permission to manage credential types. Contact your administrator to request access."
|
||||||
<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 if credentialTypes.length === 0}
|
{:else if credentialTypes.length === 0}
|
||||||
<CreateCredentialTypeModal
|
<CreateCredentialTypeModal
|
||||||
isOpen={isCreateModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
@@ -208,78 +165,24 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if typeToDelete}
|
<DeleteConfirmDialog
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
isOpen={!!typeToDelete}
|
||||||
<div
|
title="Delete Credential Type"
|
||||||
class="confirm-backdrop"
|
idValue={typeToDelete?.id ?? ""}
|
||||||
on:click={cancelDelete}
|
formAction="?/deleteCredentialType"
|
||||||
on:keydown={(e) => e.key === "Escape" && cancelDelete()}
|
confirmLabel="Delete"
|
||||||
|
{isDeleting}
|
||||||
|
error={deleteError}
|
||||||
|
onCancel={cancelDelete}
|
||||||
|
handleEnhance={handleDeleteEnhance}
|
||||||
>
|
>
|
||||||
<div
|
Are you sure you want to delete <strong>{typeToDelete?.name}</strong>?
|
||||||
class="confirm-dialog"
|
{#if typeToDelete && typeToDelete.credentialCount > 0}
|
||||||
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}
|
|
||||||
This type has <strong>{typeToDelete.credentialCount}</strong>
|
This type has <strong>{typeToDelete.credentialCount}</strong>
|
||||||
credential{typeToDelete.credentialCount === 1 ? "" : "s"} associated
|
credential{typeToDelete.credentialCount === 1 ? "" : "s"} associated with it.
|
||||||
with it.
|
|
||||||
{/if}
|
{/if}
|
||||||
This action cannot be undone.
|
This action cannot be undone.
|
||||||
</p>
|
</DeleteConfirmDialog>
|
||||||
{#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}
|
|
||||||
|
|
||||||
<div class="admin-table-header">
|
<div class="admin-table-header">
|
||||||
<h3>
|
<h3>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from "$app/forms";
|
|
||||||
import type { SubmitFunction } from "@sveltejs/kit";
|
import type { SubmitFunction } from "@sveltejs/kit";
|
||||||
import type { PermissionMap } from "$lib/permissions";
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
import type { Role } from "$lib/optima-api/modules/roles";
|
import type { Role } from "$lib/optima-api/modules/roles";
|
||||||
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
|
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 CreateRoleModal from "../../../components/CreateRoleModal.svelte";
|
||||||
|
import AccessDenied from "../../../components/AccessDenied.svelte";
|
||||||
|
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
|
||||||
import "../../../styles/admin/roles.css";
|
import "../../../styles/admin/roles.css";
|
||||||
|
|
||||||
interface RoleUser {
|
interface RoleUser {
|
||||||
@@ -47,31 +50,6 @@
|
|||||||
openMenuId = openMenuId === id ? null : id;
|
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
|
// Delete confirmation
|
||||||
let roleToDelete: Role | null = null;
|
let roleToDelete: Role | null = null;
|
||||||
let isDeleting = false;
|
let isDeleting = false;
|
||||||
@@ -109,35 +87,14 @@
|
|||||||
function toggleRole(id: string) {
|
function toggleRole(id: string) {
|
||||||
expandedRoleId = expandedRoleId === id ? null : id;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={() => (openMenuId = null)} />
|
<svelte:window on:click={() => (openMenuId = null)} />
|
||||||
|
|
||||||
{#if !hasAccess}
|
{#if !hasAccess}
|
||||||
<div class="admin-denied">
|
<AccessDenied
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
message="You don't have permission to manage roles. Contact your administrator to request access."
|
||||||
<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>
|
|
||||||
{:else if roles.length === 0}
|
{:else if roles.length === 0}
|
||||||
<CreateRoleModal
|
<CreateRoleModal
|
||||||
isOpen={isCreateModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
@@ -196,72 +153,20 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if roleToDelete}
|
<DeleteConfirmDialog
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
isOpen={!!roleToDelete}
|
||||||
<div
|
title="Delete Role"
|
||||||
class="confirm-backdrop"
|
idValue={roleToDelete?.id ?? ""}
|
||||||
on:click={cancelDelete}
|
formAction="?/deleteRole"
|
||||||
on:keydown={(e) => e.key === "Escape" && cancelDelete()}
|
confirmLabel="Delete Role"
|
||||||
|
{isDeleting}
|
||||||
|
error={deleteError}
|
||||||
|
onCancel={cancelDelete}
|
||||||
|
handleEnhance={handleDeleteEnhance}
|
||||||
>
|
>
|
||||||
<div
|
Are you sure you want to delete <strong>{roleToDelete?.title}</strong>? This
|
||||||
class="confirm-dialog"
|
action cannot be undone.
|
||||||
role="alertdialog"
|
</DeleteConfirmDialog>
|
||||||
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}
|
|
||||||
|
|
||||||
<div class="admin-table-header">
|
<div class="admin-table-header">
|
||||||
<h3>
|
<h3>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from "$app/forms";
|
|
||||||
import type { SubmitFunction } from "@sveltejs/kit";
|
import type { SubmitFunction } from "@sveltejs/kit";
|
||||||
import type { PermissionMap } from "$lib/permissions";
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
import type { User } from "$lib/optima-api/modules/users";
|
import type { User } from "$lib/optima-api/modules/users";
|
||||||
import type { Role } from "$lib/optima-api/modules/roles";
|
import type { Role } from "$lib/optima-api/modules/roles";
|
||||||
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
|
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 EditUserModal from "../../../components/EditUserModal.svelte";
|
||||||
|
import AccessDenied from "../../../components/AccessDenied.svelte";
|
||||||
|
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
|
||||||
import "../../../styles/admin/users.css";
|
import "../../../styles/admin/users.css";
|
||||||
|
|
||||||
type UserWithRoles = User & { roleDetails: Role[] };
|
type UserWithRoles = User & { roleDetails: Role[] };
|
||||||
@@ -51,30 +54,6 @@
|
|||||||
openMenuId = openMenuId === id ? null : id;
|
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
|
// Edit modal state
|
||||||
let editingUser: UserWithRoles | null = null;
|
let editingUser: UserWithRoles | null = null;
|
||||||
|
|
||||||
@@ -126,35 +105,14 @@
|
|||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.toUpperCase();
|
.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>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={() => (openMenuId = null)} />
|
<svelte:window on:click={() => (openMenuId = null)} />
|
||||||
|
|
||||||
{#if !hasAccess}
|
{#if !hasAccess}
|
||||||
<div class="admin-denied">
|
<AccessDenied
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
message="You don't have permission to manage users. Contact your administrator to request access."
|
||||||
<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 if users.length === 0}
|
{:else if users.length === 0}
|
||||||
<div class="admin-tab-empty">
|
<div class="admin-tab-empty">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -180,73 +138,21 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Delete confirmation modal -->
|
<!-- Delete confirmation dialog -->
|
||||||
{#if userToDelete}
|
<DeleteConfirmDialog
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
isOpen={!!userToDelete}
|
||||||
<div
|
title="Delete User"
|
||||||
class="confirm-backdrop"
|
idValue={userToDelete?.id ?? ""}
|
||||||
on:click={cancelDelete}
|
formAction="?/deleteUser"
|
||||||
on:keydown={(e) => e.key === "Escape" && cancelDelete()}
|
confirmLabel="Delete User"
|
||||||
|
{isDeleting}
|
||||||
|
error={deleteError}
|
||||||
|
onCancel={cancelDelete}
|
||||||
|
handleEnhance={handleDeleteEnhance}
|
||||||
>
|
>
|
||||||
<div
|
Are you sure you want to delete <strong>{userToDelete?.name}</strong>? This
|
||||||
class="confirm-dialog"
|
action cannot be undone.
|
||||||
role="alertdialog"
|
</DeleteConfirmDialog>
|
||||||
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}
|
|
||||||
|
|
||||||
<div class="admin-table-header">
|
<div class="admin-table-header">
|
||||||
<h3>
|
<h3>
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { afterNavigate } from "$app/navigation";
|
import { afterNavigate } from "$app/navigation";
|
||||||
import type { PermissionMap } from "$lib/permissions";
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
|
import { formatDate } from "$lib/utils";
|
||||||
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
||||||
|
import AccessDenied from "../../components/AccessDenied.svelte";
|
||||||
|
import Pagination from "../../components/Pagination.svelte";
|
||||||
import "../../styles/companies/companylist.css";
|
import "../../styles/companies/companylist.css";
|
||||||
|
|
||||||
export let data: {
|
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 {
|
function statusClass(status?: string): string {
|
||||||
if (!status) return "neutral";
|
if (!status) return "neutral";
|
||||||
const s = status.toLowerCase();
|
const s = status.toLowerCase();
|
||||||
@@ -123,28 +113,6 @@
|
|||||||
.join("")
|
.join("")
|
||||||
.toUpperCase();
|
.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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -152,17 +120,9 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if !hasAccess}
|
{#if !hasAccess}
|
||||||
<div class="access-denied">
|
<AccessDenied
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
message="You don't have permission to view Companies. Contact your administrator to request access."
|
||||||
<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>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="companies-page">
|
<div class="companies-page">
|
||||||
<div class="companies-pane">
|
<div class="companies-pane">
|
||||||
@@ -343,58 +303,7 @@
|
|||||||
<span class="page-info">
|
<span class="page-info">
|
||||||
Page {currentPage} of {totalPages}
|
Page {currentPage} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<nav class="pagination" aria-label="Pagination">
|
<Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -402,33 +311,4 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<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>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
import type { PermissionMap } from "$lib/permissions";
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
|
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";
|
import "../../../styles/procurement/catalog.css";
|
||||||
|
|
||||||
type CatalogItem = {
|
type CatalogItem = {
|
||||||
@@ -416,33 +419,6 @@
|
|||||||
}).format(value);
|
}).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 = (() => {
|
$: hasInactiveStaged = (() => {
|
||||||
for (const id of stagedAdds) {
|
for (const id of stagedAdds) {
|
||||||
const item = linkSearchResults.find((r) => r.id === id);
|
const item = linkSearchResults.find((r) => r.id === id);
|
||||||
@@ -466,17 +442,9 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if !hasAccess}
|
{#if !hasAccess}
|
||||||
<div class="catalog-access-denied">
|
<AccessDenied
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
message="You don't have permission to view the Product Catalog. Contact your administrator to request access."
|
||||||
<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>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="catalog-page" class:panel-open={selectedItem}>
|
<div class="catalog-page" class:panel-open={selectedItem}>
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
@@ -1481,93 +1449,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{#if totalPages > 1}
|
|
||||||
<div class="catalog-footer">
|
<div class="catalog-footer">
|
||||||
<span class="catalog-page-info">
|
<Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 { invalidateAll } from "$app/navigation";
|
||||||
import type { PermissionMap } from "$lib/permissions";
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
import type { OpportunityType } from "$lib/optima-api/modules/sales";
|
import type { OpportunityType } from "$lib/optima-api/modules/sales";
|
||||||
|
import { formatDate } from "$lib/utils";
|
||||||
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
||||||
import CreateOpportunityModal from "../../components/CreateOpportunityModal.svelte";
|
import CreateOpportunityModal from "../../components/CreateOpportunityModal.svelte";
|
||||||
|
import AccessDenied from "../../components/AccessDenied.svelte";
|
||||||
|
import Pagination from "../../components/Pagination.svelte";
|
||||||
import "../../styles/sales/sales.css";
|
import "../../styles/sales/sales.css";
|
||||||
|
|
||||||
type SalesOpportunity = {
|
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 {
|
function statusLabel(op: SalesOpportunity): string {
|
||||||
if (op.closedFlag) return "Closed";
|
if (op.closedFlag) return "Closed";
|
||||||
const statusId = op.status?.id;
|
const statusId = op.status?.id;
|
||||||
@@ -306,20 +296,6 @@
|
|||||||
s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
|
s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
|
||||||
return s;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={handleFilterClickOutside} />
|
<svelte:window on:click={handleFilterClickOutside} />
|
||||||
@@ -329,17 +305,9 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if !hasAccess}
|
{#if !hasAccess}
|
||||||
<div class="sales-access-denied">
|
<AccessDenied
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
message="You don't have permission to view Sales opportunities. Contact your administrator to request access."
|
||||||
<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>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="sales-page">
|
<div class="sales-page">
|
||||||
<div class="sales-pane">
|
<div class="sales-pane">
|
||||||
@@ -549,58 +517,7 @@
|
|||||||
<span class="sales-page-info">
|
<span class="sales-page-info">
|
||||||
Page {currentPage} of {totalPages}
|
Page {currentPage} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<nav class="sales-pagination" aria-label="Sales pagination">
|
<Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -613,36 +530,6 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.sales-create-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
import { invalidateAll } from "$app/navigation";
|
import { invalidateAll } from "$app/navigation";
|
||||||
import type { PermissionMap } from "$lib/permissions";
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
import type { OpportunityType } from "$lib/optima-api/modules/sales";
|
import type { OpportunityType } from "$lib/optima-api/modules/sales";
|
||||||
|
import { formatDate } from "$lib/utils";
|
||||||
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
|
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
|
||||||
import CreateOpportunityModal from "../../../components/CreateOpportunityModal.svelte";
|
import CreateOpportunityModal from "../../../components/CreateOpportunityModal.svelte";
|
||||||
|
import AccessDenied from "../../../components/AccessDenied.svelte";
|
||||||
|
import Pagination from "../../../components/Pagination.svelte";
|
||||||
import "../../../styles/sales/sales.css";
|
import "../../../styles/sales/sales.css";
|
||||||
|
|
||||||
type SalesOpportunity = {
|
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 {
|
function statusLabel(op: SalesOpportunity): string {
|
||||||
if (op.closedFlag) return "Closed";
|
if (op.closedFlag) return "Closed";
|
||||||
const statusId = op.status?.id;
|
const statusId = op.status?.id;
|
||||||
@@ -307,20 +297,6 @@
|
|||||||
s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
|
s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
|
||||||
return s;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={handleFilterClickOutside} />
|
<svelte:window on:click={handleFilterClickOutside} />
|
||||||
@@ -330,17 +306,9 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if !hasAccess}
|
{#if !hasAccess}
|
||||||
<div class="sales-access-denied">
|
<AccessDenied
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
message="You don't have permission to view Sales opportunities. Contact your administrator to request access."
|
||||||
<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>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="sales-page">
|
<div class="sales-page">
|
||||||
<div class="sales-pane">
|
<div class="sales-pane">
|
||||||
@@ -550,58 +518,7 @@
|
|||||||
<span class="sales-page-info">
|
<span class="sales-page-info">
|
||||||
Page {currentPage} of {totalPages}
|
Page {currentPage} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<nav class="sales-pagination" aria-label="Sales pagination">
|
<Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -614,36 +531,6 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.sales-create-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user