Merge remote-tracking branch 'origin/master' into ajustes_gerais
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
"vite": "^7.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ark-ui/svelte": "^5.15.0",
|
||||
"@convex-dev/better-auth": "^0.9.7",
|
||||
"@dicebear/collection": "^9.2.4",
|
||||
"@dicebear/core": "^9.2.4",
|
||||
@@ -60,6 +61,7 @@
|
||||
"marked": "^17.0.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
"theme-change": "^2.5.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx-js-style": "^1.2.0",
|
||||
"zod": "^4.1.12"
|
||||
|
||||
@@ -21,405 +21,348 @@
|
||||
@apply border-error bg-base-100 hover:bg-error/60 active:bg-error text-error flex items-center justify-center gap-2 rounded-xl border px-4 py-2 text-center font-medium transition-colors hover:text-white active:text-white;
|
||||
}
|
||||
|
||||
/* Tema Aqua (padrão roxo/azul) - customizado para garantir funcionamento */
|
||||
html[data-theme='aqua'],
|
||||
html[data-theme='aqua'] body,
|
||||
[data-theme='aqua'] {
|
||||
/* Tema Aqua (padrão roxo/azul) - redefinido como custom para garantir compatibilidade */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'aqua';
|
||||
default: true;
|
||||
color-scheme: light;
|
||||
--p: 217 91% 60%;
|
||||
--pf: 217 91% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 217 91% 60%;
|
||||
--sf: 217 91% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 217 91% 60%;
|
||||
--af: 217 91% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 217 20% 17%;
|
||||
--nf: 217 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 217 20% 95%;
|
||||
--b3: 217 20% 90%;
|
||||
--bc: 217 20% 17%;
|
||||
--in: 217 91% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
/* Azul principal (ligeiramente mais escuro que o anterior) */
|
||||
--color-primary: hsl(217 91% 55%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(217 91% 55%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(217 91% 55%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(217 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(217 20% 95%);
|
||||
--color-base-300: hsl(217 20% 90%);
|
||||
--color-base-content: hsl(217 20% 17%);
|
||||
--color-info: hsl(217 91% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* Temas customizados para SGSE - Azul */
|
||||
html[data-theme='sgse-blue'],
|
||||
html[data-theme='sgse-blue'] body,
|
||||
[data-theme='sgse-blue'] {
|
||||
/* Temas customizados para SGSE */
|
||||
|
||||
/* Azul */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-blue';
|
||||
color-scheme: light;
|
||||
--p: 217 91% 60%;
|
||||
--pf: 217 91% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 217 91% 60%;
|
||||
--sf: 217 91% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 217 91% 60%;
|
||||
--af: 217 91% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 217 20% 17%;
|
||||
--nf: 217 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 217 20% 95%;
|
||||
--b3: 217 20% 90%;
|
||||
--bc: 217 20% 17%;
|
||||
--in: 217 91% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
--color-primary: hsl(217 91% 55%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(217 91% 55%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(217 91% 55%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(217 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(217 20% 95%);
|
||||
--color-base-300: hsl(217 20% 90%);
|
||||
--color-base-content: hsl(217 20% 17%);
|
||||
--color-info: hsl(217 91% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* Garantir que todas as variáveis CSS sejam aplicadas em todos os elementos */
|
||||
html[data-theme] {
|
||||
color-scheme: var(--color-scheme, light);
|
||||
}
|
||||
|
||||
html[data-theme] * {
|
||||
color-scheme: inherit;
|
||||
}
|
||||
|
||||
html[data-theme='sgse-green'],
|
||||
html[data-theme='sgse-green'] body,
|
||||
[data-theme='sgse-green'] {
|
||||
/* Verde */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-green';
|
||||
color-scheme: light;
|
||||
--p: 142 76% 36%;
|
||||
--pf: 142 76% 26%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 142 76% 36%;
|
||||
--sf: 142 76% 26%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 142 76% 36%;
|
||||
--af: 142 76% 26%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 142 20% 17%;
|
||||
--nf: 142 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 142 20% 95%;
|
||||
--b3: 142 20% 90%;
|
||||
--bc: 142 20% 17%;
|
||||
--in: 142 76% 36%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
--color-primary: hsl(142 76% 36%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(142 76% 36%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(142 76% 36%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(142 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(142 20% 95%);
|
||||
--color-base-300: hsl(142 20% 90%);
|
||||
--color-base-content: hsl(142 20% 17%);
|
||||
--color-info: hsl(142 76% 36%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
html[data-theme='sgse-orange'],
|
||||
html[data-theme='sgse-orange'] body,
|
||||
[data-theme='sgse-orange'] {
|
||||
/* Laranja */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-orange';
|
||||
color-scheme: light;
|
||||
--p: 25 95% 53%;
|
||||
--pf: 25 95% 43%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 25 95% 53%;
|
||||
--sf: 25 95% 43%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 25 95% 53%;
|
||||
--af: 25 95% 43%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 25 20% 17%;
|
||||
--nf: 25 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 25 20% 95%;
|
||||
--b3: 25 20% 90%;
|
||||
--bc: 25 20% 17%;
|
||||
--in: 25 95% 53%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
--color-primary: hsl(25 95% 53%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(25 95% 53%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(25 95% 53%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(25 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(25 20% 95%);
|
||||
--color-base-300: hsl(25 20% 90%);
|
||||
--color-base-content: hsl(25 20% 17%);
|
||||
--color-info: hsl(25 95% 53%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
html[data-theme='sgse-red'],
|
||||
html[data-theme='sgse-red'] body,
|
||||
[data-theme='sgse-red'] {
|
||||
/* Vermelho */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-red';
|
||||
color-scheme: light;
|
||||
--p: 0 84% 60%;
|
||||
--pf: 0 84% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 0 84% 60%;
|
||||
--sf: 0 84% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 0 84% 60%;
|
||||
--af: 0 84% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 0 20% 17%;
|
||||
--nf: 0 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 0 20% 95%;
|
||||
--b3: 0 20% 90%;
|
||||
--bc: 0 20% 17%;
|
||||
--in: 0 84% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
--color-primary: hsl(0 84% 60%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(0 84% 60%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(0 84% 60%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(0 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(0 20% 95%);
|
||||
--color-base-300: hsl(0 20% 90%);
|
||||
--color-base-content: hsl(0 20% 17%);
|
||||
--color-info: hsl(0 84% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
html[data-theme='sgse-pink'],
|
||||
html[data-theme='sgse-pink'] body,
|
||||
[data-theme='sgse-pink'] {
|
||||
/* Rosa */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-pink';
|
||||
color-scheme: light;
|
||||
--p: 330 81% 60%;
|
||||
--pf: 330 81% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 330 81% 60%;
|
||||
--sf: 330 81% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 330 81% 60%;
|
||||
--af: 330 81% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 330 20% 17%;
|
||||
--nf: 330 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 330 20% 95%;
|
||||
--b3: 330 20% 90%;
|
||||
--bc: 330 20% 17%;
|
||||
--in: 330 81% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
--color-primary: hsl(330 81% 60%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(330 81% 60%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(330 81% 60%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(330 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(330 20% 95%);
|
||||
--color-base-300: hsl(330 20% 90%);
|
||||
--color-base-content: hsl(330 20% 17%);
|
||||
--color-info: hsl(330 81% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
html[data-theme='sgse-teal'],
|
||||
html[data-theme='sgse-teal'] body,
|
||||
[data-theme='sgse-teal'] {
|
||||
/* Teal */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-teal';
|
||||
color-scheme: light;
|
||||
--p: 173 80% 40%;
|
||||
--pf: 173 80% 30%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 173 80% 40%;
|
||||
--sf: 173 80% 30%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 173 80% 40%;
|
||||
--af: 173 80% 30%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 173 20% 17%;
|
||||
--nf: 173 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 173 20% 95%;
|
||||
--b3: 173 20% 90%;
|
||||
--bc: 173 20% 17%;
|
||||
--in: 173 80% 40%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
--color-primary: hsl(173 80% 40%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(173 80% 40%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(173 80% 40%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(173 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(173 20% 95%);
|
||||
--color-base-300: hsl(173 20% 90%);
|
||||
--color-base-content: hsl(173 20% 17%);
|
||||
--color-info: hsl(173 80% 40%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
html[data-theme='sgse-corporate'],
|
||||
html[data-theme='sgse-corporate'] body,
|
||||
[data-theme='sgse-corporate'] {
|
||||
/* Corporativo (Dark Blue) */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-corporate';
|
||||
color-scheme: dark;
|
||||
--p: 217 91% 60%;
|
||||
--pf: 217 91% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 217 91% 60%;
|
||||
--sf: 217 91% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 217 91% 60%;
|
||||
--af: 217 91% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 217 30% 15%;
|
||||
--nf: 217 30% 8%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 217 30% 10%;
|
||||
--b2: 217 30% 15%;
|
||||
--b3: 217 30% 20%;
|
||||
--bc: 217 10% 90%;
|
||||
--in: 217 91% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
--color-primary: hsl(217 91% 55%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(217 91% 55%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(217 91% 55%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(217 30% 15%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
/* Aproxima do fundo do login (Tailwind slate-900 = #0f172a) */
|
||||
--color-base-100: hsl(222 47% 11%);
|
||||
/* Escala de contraste (slate-800 / slate-700 aproximados) */
|
||||
--color-base-200: hsl(215 28% 17%);
|
||||
--color-base-300: hsl(215 25% 23%);
|
||||
--color-base-content: hsl(217 10% 90%);
|
||||
--color-info: hsl(217 91% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* Tema Light customizado para garantir funcionamento completo */
|
||||
html[data-theme='light'],
|
||||
html[data-theme='light'] body,
|
||||
[data-theme='light'] {
|
||||
/* Light */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'light';
|
||||
color-scheme: light;
|
||||
--p: 217 91% 60%;
|
||||
--pf: 217 91% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 217 91% 60%;
|
||||
--sf: 217 91% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 217 91% 60%;
|
||||
--af: 217 91% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 217 20% 17%;
|
||||
--nf: 217 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 217 20% 95%;
|
||||
--b3: 217 20% 90%;
|
||||
--bc: 217 20% 17%;
|
||||
--in: 217 91% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
--color-primary: hsl(217 91% 55%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(217 91% 55%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(217 91% 55%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(217 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(217 20% 95%);
|
||||
--color-base-300: hsl(217 20% 90%);
|
||||
--color-base-content: hsl(217 20% 17%);
|
||||
--color-info: hsl(217 91% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* Tema Dark customizado para garantir funcionamento completo */
|
||||
html[data-theme='dark'],
|
||||
html[data-theme='dark'] body,
|
||||
[data-theme='dark'] {
|
||||
/* Dark */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'dark';
|
||||
color-scheme: dark;
|
||||
--p: 217 91% 60%;
|
||||
--pf: 217 91% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 217 91% 60%;
|
||||
--sf: 217 91% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 217 91% 60%;
|
||||
--af: 217 91% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 217 30% 15%;
|
||||
--nf: 217 30% 8%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 217 30% 10%;
|
||||
--b2: 217 30% 15%;
|
||||
--b3: 217 30% 20%;
|
||||
--bc: 217 10% 90%;
|
||||
--in: 217 91% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
--color-primary: hsl(217 91% 55%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(217 91% 55%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(217 91% 55%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(217 30% 15%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(217 30% 10%);
|
||||
--color-base-200: hsl(217 30% 15%);
|
||||
--color-base-300: hsl(217 30% 20%);
|
||||
--color-base-content: hsl(217 10% 90%);
|
||||
--color-info: hsl(217 91% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@@ -111,8 +111,17 @@
|
||||
(function () {
|
||||
if (typeof document !== 'undefined') {
|
||||
var html = document.documentElement;
|
||||
if (!html.getAttribute('data-theme')) {
|
||||
html.setAttribute('data-theme', 'aqua');
|
||||
if (html && !html.getAttribute('data-theme')) {
|
||||
var tema = null;
|
||||
try {
|
||||
// theme-change usa por padrão a chave "theme"
|
||||
tema = localStorage.getItem('theme');
|
||||
} catch (e) {
|
||||
tema = null;
|
||||
}
|
||||
|
||||
// Fallback para o tema padrão se não houver persistência
|
||||
html.setAttribute('data-theme', tema || 'aqua');
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { loginModalStore } from '$lib/stores/loginModal.svelte';
|
||||
import { TriangleAlert } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
recurso: string;
|
||||
acao: string;
|
||||
children?: any;
|
||||
}
|
||||
|
||||
let { recurso, acao, children }: Props = $props();
|
||||
|
||||
let verificando = $state(true);
|
||||
let permitido = $state(false);
|
||||
|
||||
// Usuário atual
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
let permissaoQuery = $derived(
|
||||
currentUser?.data
|
||||
? useQuery(api.permissoesAcoes.verificarAcao, {
|
||||
usuarioId: currentUser.data._id as Id<'usuarios'>,
|
||||
recurso,
|
||||
acao
|
||||
})
|
||||
: null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!currentUser?.data) {
|
||||
verificando = false;
|
||||
permitido = false;
|
||||
const currentPath = window.location.pathname;
|
||||
loginModalStore.open(currentPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (permissaoQuery?.error) {
|
||||
verificando = false;
|
||||
permitido = false;
|
||||
} else if (permissaoQuery && !permissaoQuery.isLoading) {
|
||||
// Backend retorna null quando permitido
|
||||
verificando = false;
|
||||
permitido = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if verificando}
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="text-base-content/70 mt-4">Verificando permissões...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if permitido}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="bg-error/10 mb-4 inline-block rounded-full p-4">
|
||||
<TriangleAlert class="text-error h-16 w-16" strokeWidth={2} />
|
||||
</div>
|
||||
<h2 class="text-base-content mb-2 text-2xl font-bold">Acesso Negado</h2>
|
||||
<p class="text-base-content/70">Você não tem permissão para acessar esta ação.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={['absolute inset-0 h-full w-full', className]}>
|
||||
<div
|
||||
class="bg-primary/20 absolute top-[-10%] left-[-10%] h-[40%] w-[40%] animate-pulse rounded-full blur-[120px]"
|
||||
></div>
|
||||
<div
|
||||
class="bg-secondary/20 absolute right-[-10%] bottom-[-10%] h-[40%] w-[40%] animate-pulse rounded-full blur-[120px] delay-700"
|
||||
></div>
|
||||
</div>
|
||||
14
apps/web/src/lib/components/DecorativeTopLine.svelte
Normal file
14
apps/web/src/lib/components/DecorativeTopLine.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'via-primary absolute top-0 left-0 h-1 w-full bg-linear-to-r from-transparent to-transparent opacity-50',
|
||||
className
|
||||
]}
|
||||
></div>
|
||||
22
apps/web/src/lib/components/ErrorMessage.svelte
Normal file
22
apps/web/src/lib/components/ErrorMessage.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { XCircle } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
message?: string | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { message = null, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if message}
|
||||
<div
|
||||
class={[
|
||||
'border-error/20 bg-error/10 text-error-content/90 mb-6 flex items-center gap-3 rounded-lg border p-4 backdrop-blur-md',
|
||||
className
|
||||
]}
|
||||
>
|
||||
<XCircle class="h-5 w-5 shrink-0" />
|
||||
<span class="text-sm font-medium">{message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
57
apps/web/src/lib/components/Footer.svelte
Normal file
57
apps/web/src/lib/components/Footer.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<footer class="bg-base-200 text-base-content border-base-300 mt-16 border-t">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="grid grid-cols-1 gap-8 text-center md:grid-cols-3 md:text-left">
|
||||
<div>
|
||||
<h3 class="text-primary mb-4 text-lg font-bold">SGSE</h3>
|
||||
<p class="mx-auto max-w-xs text-sm opacity-75 md:mx-0">
|
||||
Sistema de Gestão de Secretaria<br />
|
||||
Simplificando processos e conectando pessoas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-bold">Links Úteis</h3>
|
||||
<ul class="space-y-2 text-sm opacity-75">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.pe.gov.br/"
|
||||
target="_blank"
|
||||
class="hover:text-primary transition-colors">Portal do Governo</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href={resolve('/privacidade')} class="hover:text-primary transition-colors"
|
||||
>Política de Privacidade</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href={resolve('/abrir-chamado')} class="hover:text-primary transition-colors"
|
||||
>Suporte</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-bold">Contato</h3>
|
||||
<p class="text-sm opacity-75">
|
||||
Secretaria de Educação<br />
|
||||
Recife - PE
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider mt-8 mb-4"></div>
|
||||
|
||||
<div class="flex flex-col items-center justify-between text-sm opacity-60 md:flex-row">
|
||||
<p>© {currentYear} Governo de Pernambuco. Todos os direitos reservados.</p>
|
||||
<p class="mt-2 md:mt-0">Desenvolvido com tecnologia de ponta.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
19
apps/web/src/lib/components/GlassCard.svelte
Normal file
19
apps/web/src/lib/components/GlassCard.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'border-base-content/10 bg-base-content/5 ring-base-content/10 relative overflow-hidden rounded-2xl border p-8 shadow-2xl ring-1 backdrop-blur-xl transition-all duration-300',
|
||||
className
|
||||
]}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -1,7 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import logo from '$lib/assets/logo_governo_PE.png';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type HeaderProps = {
|
||||
left?: Snippet;
|
||||
right?: Snippet;
|
||||
};
|
||||
|
||||
const { left, right }: HeaderProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="navbar bg-base-200 w-76 p-4 shadow-sm">
|
||||
<img src={logo} alt="Logo" class="" />
|
||||
</div>
|
||||
<header
|
||||
class="bg-base-200 border-base-100 sticky top-0 z-50 w-full border-b py-3 shadow-sm backdrop-blur-md transition-all duration-300"
|
||||
>
|
||||
<div class=" flex h-16 w-full items-center justify-between px-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if left}
|
||||
{@render left()}
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href={resolve('/')}
|
||||
class="group flex items-center gap-3 transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
<img src={logo} alt="Logo Governo PE" class="h-10 w-auto object-contain drop-shadow-sm" />
|
||||
<div class="hidden flex-col sm:flex">
|
||||
<span class="text-primary text-2xl font-bold tracking-wider uppercase">SGSE</span>
|
||||
<span class="text-base-content -mt-1 text-lg leading-none font-extrabold tracking-tight"
|
||||
>Sistema de Gestão da Secretaria de Esportes</span
|
||||
>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
class="select select-sm bg-base-100 border-base-300 w-40"
|
||||
aria-label="Selecionar tema"
|
||||
data-choose-theme
|
||||
>
|
||||
<option value="aqua">Aqua</option>
|
||||
<option value="sgse-blue">Azul</option>
|
||||
<option value="sgse-green">Verde</option>
|
||||
<option value="sgse-orange">Laranja</option>
|
||||
<option value="sgse-red">Vermelho</option>
|
||||
<option value="sgse-pink">Rosa</option>
|
||||
<option value="sgse-teal">Verde-água</option>
|
||||
<option value="sgse-corporate">Corporativo</option>
|
||||
<option value="light">Claro</option>
|
||||
<option value="dark">Escuro</option>
|
||||
</select>
|
||||
|
||||
{#if right}
|
||||
{@render right()}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { loginModalStore } from '$lib/stores/loginModal.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface MenuProtectionProps {
|
||||
menuPath: string;
|
||||
requireGravar?: boolean;
|
||||
children?: any;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
menuPath,
|
||||
requireGravar = false,
|
||||
children,
|
||||
redirectTo = '/'
|
||||
}: MenuProtectionProps = $props();
|
||||
|
||||
let verificando = $state(true);
|
||||
let temPermissao = $state(false);
|
||||
let motivoNegacao = $state('');
|
||||
|
||||
// Query para verificar permissões (só executa se o usuário estiver autenticado)
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
let permissaoQuery = $derived(
|
||||
currentUser?.data
|
||||
? useQuery(api.menuPermissoes.verificarAcesso, {
|
||||
usuarioId: currentUser.data._id as Id<'usuarios'>,
|
||||
menuPath: menuPath
|
||||
})
|
||||
: null
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
verificarPermissoes();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Re-verificar quando o status do usuário atual mudar
|
||||
verificarPermissoes();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Re-verificar quando a query carregar
|
||||
if (permissaoQuery?.data) {
|
||||
verificarPermissoes();
|
||||
}
|
||||
});
|
||||
|
||||
function verificarPermissoes() {
|
||||
// Dashboard e abertura de chamados são públicos
|
||||
if (menuPath === '/' || menuPath === '/abrir-chamado') {
|
||||
verificando = false;
|
||||
temPermissao = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Se não está autenticado
|
||||
if (!currentUser?.data) {
|
||||
verificando = false;
|
||||
temPermissao = false;
|
||||
motivoNegacao = 'auth_required';
|
||||
|
||||
// Abrir modal de login e salvar rota de redirecionamento
|
||||
const currentPath = window.location.pathname;
|
||||
loginModalStore.open(currentPath);
|
||||
|
||||
// NÃO redirecionar, apenas mostrar o modal
|
||||
// O usuário verá a mensagem "Verificando permissões..." enquanto o modal está aberto
|
||||
return;
|
||||
}
|
||||
|
||||
// Se está autenticado, verificar permissões
|
||||
if (permissaoQuery?.data) {
|
||||
const permissao = permissaoQuery.data;
|
||||
|
||||
// Se não pode acessar
|
||||
if (!permissao.podeAcessar) {
|
||||
verificando = false;
|
||||
temPermissao = false;
|
||||
motivoNegacao = 'access_denied';
|
||||
return;
|
||||
}
|
||||
|
||||
// Se requer gravação mas não tem permissão
|
||||
if (requireGravar && !permissao.podeGravar) {
|
||||
verificando = false;
|
||||
temPermissao = false;
|
||||
motivoNegacao = 'write_denied';
|
||||
return;
|
||||
}
|
||||
|
||||
// Tem permissão!
|
||||
verificando = false;
|
||||
temPermissao = true;
|
||||
} else if (permissaoQuery?.error) {
|
||||
verificando = false;
|
||||
temPermissao = false;
|
||||
motivoNegacao = 'error';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if verificando}
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
{#if motivoNegacao === 'auth_required'}
|
||||
<div class="bg-warning/10 mb-4 inline-block rounded-full p-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-warning h-16 w-16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-base-content mb-2 text-2xl font-bold">Acesso Restrito</h2>
|
||||
<p class="text-base-content/70 mb-4">
|
||||
Esta área requer autenticação.<br />
|
||||
Por favor, faça login para continuar.
|
||||
</p>
|
||||
{:else}
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="text-base-content/70 mt-4">Verificando permissões...</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if temPermissao}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="bg-error/10 mb-4 inline-block rounded-full p-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-error h-16 w-16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-base-content mb-2 text-2xl font-bold">Acesso Negado</h2>
|
||||
<p class="text-base-content/70">Você não tem permissão para acessar esta página.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
85
apps/web/src/lib/components/MenuToggleIcon.svelte
Normal file
85
apps/web/src/lib/components/MenuToggleIcon.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { prefersReducedMotion, Spring } from 'svelte/motion';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
class?: string;
|
||||
stroke?: number;
|
||||
}
|
||||
|
||||
let { open, class: className = '', stroke = 2 }: Props = $props();
|
||||
|
||||
const progress = Spring.of(() => (open ? 1 : 0), {
|
||||
stiffness: 0.25,
|
||||
damping: 0.65,
|
||||
precision: 0.001
|
||||
});
|
||||
|
||||
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
|
||||
let t = $derived(prefersReducedMotion.current ? (open ? 1 : 0) : progress.current);
|
||||
let tFast = $derived(clamp01(t * 1.15));
|
||||
|
||||
// Fechado: hambúrguer. Aberto: "outro menu" (linhas deslocadas + comprimentos diferentes).
|
||||
// Continua sendo ícone de menu (não vira X).
|
||||
let topY = $derived(lerp(-6, -7, tFast));
|
||||
let botY = $derived(lerp(6, 7, tFast));
|
||||
|
||||
let topX = $derived(lerp(0, 3.25, t));
|
||||
let midX = $derived(lerp(0, -2.75, t));
|
||||
let botX = $derived(lerp(0, 1.75, t));
|
||||
|
||||
// micro-inclinação só pra dar “vida”, sem cruzar em X
|
||||
let topR = $derived(lerp(0, 2.5, tFast));
|
||||
let botR = $derived(lerp(0, -2.5, tFast));
|
||||
|
||||
let topScaleX = $derived(lerp(1, 0.62, tFast));
|
||||
let midScaleX = $derived(lerp(1, 0.92, tFast));
|
||||
let botScaleX = $derived(lerp(1, 0.72, tFast));
|
||||
|
||||
let topOpacity = $derived(1);
|
||||
let midOpacity = $derived(1);
|
||||
let botOpacity = $derived(1);
|
||||
</script>
|
||||
|
||||
<span class="menu-toggle-icon {className}" aria-hidden="true" style="--stroke: {stroke}px">
|
||||
<span
|
||||
class="line"
|
||||
style="--x: {topX}px; --y: {topY}px; --r: {topR}deg; --o: {topOpacity}; --sx: {topScaleX}"
|
||||
></span>
|
||||
<span
|
||||
class="line"
|
||||
style="--x: {midX}px; --y: 0px; --r: 0deg; --o: {midOpacity}; --sx: {midScaleX}"
|
||||
></span>
|
||||
<span
|
||||
class="line"
|
||||
style="--x: {botX}px; --y: {botY}px; --r: {botR}deg; --o: {botOpacity}; --sx: {botScaleX}"
|
||||
></span>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.menu-toggle-icon {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
margin-top: calc(var(--stroke) / -2);
|
||||
height: var(--stroke);
|
||||
border-radius: 9999px;
|
||||
background: currentColor;
|
||||
opacity: var(--o, 1);
|
||||
transform-origin: center;
|
||||
transform: translateX(var(--x, 0px)) translateY(var(--y, 0px)) rotate(var(--r, 0deg))
|
||||
scaleX(var(--sx, 1));
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
</style>
|
||||
14
apps/web/src/lib/components/ShineEffect.svelte
Normal file
14
apps/web/src/lib/components/ShineEffect.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'via-base-content/20 absolute inset-0 -translate-x-full bg-linear-to-r from-transparent to-transparent transition-transform duration-1000 group-hover:translate-x-full',
|
||||
className
|
||||
]}
|
||||
></div>
|
||||
@@ -1,54 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import type { FunctionReference } from 'convex/server';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import {
|
||||
Home,
|
||||
User,
|
||||
UserPlus,
|
||||
XCircle,
|
||||
Users,
|
||||
DollarSign,
|
||||
ChevronDown,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
ShoppingCart,
|
||||
Scale,
|
||||
Megaphone,
|
||||
Trophy,
|
||||
Briefcase,
|
||||
UserCog,
|
||||
Monitor,
|
||||
ChevronDown,
|
||||
GitMerge,
|
||||
Home,
|
||||
Settings,
|
||||
Check,
|
||||
LogIn,
|
||||
Menu,
|
||||
Plus,
|
||||
Tag
|
||||
Tag,
|
||||
Users,
|
||||
Briefcase,
|
||||
UserPlus
|
||||
} from 'lucide-svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import logo from '$lib/assets/logo_governo_PE.png';
|
||||
import { authClient } from '$lib/auth';
|
||||
import ChatWidget from '$lib/components/chat/ChatWidget.svelte';
|
||||
import NotificationBell from '$lib/components/chat/NotificationBell.svelte';
|
||||
import PresenceManager from '$lib/components/chat/PresenceManager.svelte';
|
||||
import { loginModalStore } from '$lib/stores/loginModal.svelte';
|
||||
import { obterIPPublico } from '$lib/utils/deviceInfo';
|
||||
|
||||
interface GPSLocation {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
precisao?: number;
|
||||
endereco?: string;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
pais?: string;
|
||||
}
|
||||
|
||||
interface MenuItemPermission {
|
||||
recurso: string;
|
||||
@@ -211,26 +178,22 @@
|
||||
|
||||
type IconType = typeof Home;
|
||||
|
||||
const { children }: { children: Snippet } = $props();
|
||||
type SidebarProps = {
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
const { onNavigate }: SidebarProps = $props();
|
||||
|
||||
let currentPath = $derived(page.url.pathname);
|
||||
|
||||
let matricula = $state('');
|
||||
let senha = $state('');
|
||||
let erroLogin = $state('');
|
||||
let carregandoLogin = $state(false);
|
||||
let showAboutModal = $state(false);
|
||||
|
||||
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>, {});
|
||||
const permissionsQuery = useQuery(api.menu.getUserPermissions as FunctionReference<'query'>, {});
|
||||
|
||||
// Filtrar menu baseado nas permissões do usuário
|
||||
function filterSubmenusByPermissions(
|
||||
items: SubMenuItem[],
|
||||
items: readonly SubMenuItem[],
|
||||
isMaster: boolean,
|
||||
permissionsSet: Set<string>
|
||||
): SubMenuItem[] {
|
||||
if (isMaster) return items;
|
||||
if (isMaster) return [...items];
|
||||
|
||||
return items.filter((item) => {
|
||||
if (!item.permission) return true;
|
||||
@@ -240,11 +203,11 @@
|
||||
}
|
||||
|
||||
function filterMenuByPermissions(
|
||||
items: MenuItem[],
|
||||
items: readonly MenuItem[],
|
||||
isMaster: boolean,
|
||||
permissionsSet: Set<string>
|
||||
): MenuItem[] {
|
||||
if (isMaster) return items;
|
||||
if (isMaster) return [...items];
|
||||
|
||||
const filtered: MenuItem[] = [];
|
||||
|
||||
@@ -283,31 +246,16 @@
|
||||
return filterMenuByPermissions(MENU_STRUCTURE, data.isMaster, permissionsSet);
|
||||
});
|
||||
|
||||
const convexClient = useConvexClient();
|
||||
|
||||
const iconMap: Record<string, IconType> = {
|
||||
Home,
|
||||
User,
|
||||
UserPlus,
|
||||
XCircle,
|
||||
Users,
|
||||
DollarSign,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
ShoppingCart,
|
||||
Scale,
|
||||
Megaphone,
|
||||
Trophy,
|
||||
Briefcase,
|
||||
UserCog,
|
||||
Monitor,
|
||||
ChevronDown,
|
||||
GitMerge,
|
||||
Settings,
|
||||
Check,
|
||||
LogIn,
|
||||
Menu,
|
||||
Plus,
|
||||
Tag
|
||||
};
|
||||
|
||||
@@ -350,718 +298,102 @@
|
||||
function getSolicitarClasses(active: boolean) {
|
||||
return getMenuClasses(active);
|
||||
}
|
||||
|
||||
// Função para obter a URL do avatar/foto do usuário
|
||||
let avatarUrlDoUsuario = $derived.by(() => {
|
||||
if (!currentUser.data) return null;
|
||||
|
||||
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
|
||||
if (currentUser.data.fotoPerfilUrl) {
|
||||
return currentUser.data.fotoPerfilUrl;
|
||||
}
|
||||
|
||||
if (currentUser.data.avatar) {
|
||||
return currentUser.data.avatar;
|
||||
}
|
||||
|
||||
// Fallback: retornar null para usar o ícone User do Lucide
|
||||
return null;
|
||||
});
|
||||
|
||||
function openLoginModal() {
|
||||
loginModalStore.open();
|
||||
matricula = '';
|
||||
senha = '';
|
||||
erroLogin = '';
|
||||
carregandoLogin = false;
|
||||
}
|
||||
|
||||
function closeLoginModal() {
|
||||
loginModalStore.close();
|
||||
matricula = '';
|
||||
senha = '';
|
||||
erroLogin = '';
|
||||
carregandoLogin = false;
|
||||
}
|
||||
|
||||
function openAboutModal() {
|
||||
showAboutModal = true;
|
||||
}
|
||||
|
||||
function closeAboutModal() {
|
||||
showAboutModal = false;
|
||||
}
|
||||
|
||||
async function handleLogin(e: Event) {
|
||||
e.preventDefault();
|
||||
erroLogin = '';
|
||||
carregandoLogin = true;
|
||||
|
||||
// Obter IP público e userAgent (rápido, não bloqueia)
|
||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : undefined;
|
||||
|
||||
// Obter IP público com timeout curto (não bloquear login)
|
||||
const ipPublicoPromise = obterIPPublico().catch(() => undefined);
|
||||
const ipPublicoTimeout = new Promise<undefined>(
|
||||
(resolve) => setTimeout(() => resolve(undefined), 2000) // Timeout de 2 segundos
|
||||
);
|
||||
const ipPublico = await Promise.race([ipPublicoPromise, ipPublicoTimeout]);
|
||||
|
||||
// Função para coletar GPS em background (não bloqueia login)
|
||||
async function coletarGPS(): Promise<GPSLocation> {
|
||||
try {
|
||||
const { obterLocalizacaoRapida } = await import('$lib/utils/deviceInfo');
|
||||
// Usar versão rápida com timeout curto (3 segundos máximo)
|
||||
const gpsPromise = obterLocalizacaoRapida();
|
||||
const gpsTimeout = new Promise<GPSLocation>((resolve) =>
|
||||
setTimeout(() => resolve({}), 3000)
|
||||
);
|
||||
return await Promise.race([gpsPromise, gpsTimeout]);
|
||||
} catch (err) {
|
||||
console.warn('Erro ao obter GPS (não bloqueia login):', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Iniciar coleta de GPS em background (não esperar)
|
||||
const gpsPromise = coletarGPS();
|
||||
|
||||
const result = await authClient.signIn.email(
|
||||
{ email: matricula.trim(), password: senha },
|
||||
{
|
||||
onError: async (ctx) => {
|
||||
// Registrar tentativa de login falha
|
||||
try {
|
||||
// Tentar obter GPS se já estiver disponível (não esperar)
|
||||
let localizacaoGPS: GPSLocation = {};
|
||||
try {
|
||||
localizacaoGPS = await Promise.race([
|
||||
gpsPromise,
|
||||
new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
|
||||
]);
|
||||
} catch {
|
||||
// Ignorar se GPS não estiver pronto
|
||||
}
|
||||
|
||||
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
|
||||
matriculaOuEmail: matricula.trim(),
|
||||
sucesso: false,
|
||||
motivoFalha: ctx.error?.message || 'Erro desconhecido',
|
||||
userAgent: userAgent,
|
||||
ipAddress: ipPublico,
|
||||
latitudeGPS: localizacaoGPS.latitude,
|
||||
longitudeGPS: localizacaoGPS.longitude,
|
||||
precisaoGPS: localizacaoGPS.precisao,
|
||||
enderecoGPS: localizacaoGPS.endereco,
|
||||
cidadeGPS: localizacaoGPS.cidade,
|
||||
estadoGPS: localizacaoGPS.estado,
|
||||
paisGPS: localizacaoGPS.pais
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro ao registrar tentativa de login falha:', err);
|
||||
}
|
||||
alert(ctx.error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (result.data) {
|
||||
// Registrar tentativa de login bem-sucedida
|
||||
// Fazer de forma assíncrona para não bloquear o login
|
||||
// Não tentamos buscar getCurrentUser aqui porque pode causar timeout
|
||||
// O useQuery no componente já busca o usuário automaticamente quando a sessão estiver pronta
|
||||
(async () => {
|
||||
try {
|
||||
// Aguardar um pouco para o usuário ser sincronizado no Convex
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Tentar obter GPS se já estiver disponível (não esperar)
|
||||
let localizacaoGPS: GPSLocation = {};
|
||||
try {
|
||||
localizacaoGPS = await Promise.race([
|
||||
gpsPromise,
|
||||
new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
|
||||
]);
|
||||
} catch {
|
||||
// Ignorar se GPS não estiver pronto
|
||||
}
|
||||
|
||||
// Buscar o usuário no Convex usando getCurrentUser
|
||||
// (o typesafe FunctionReference pode falhar aqui; tipamos o retorno e mantemos a chamada)
|
||||
const usuario = (await convexClient.query(
|
||||
api.auth.getCurrentUser as unknown as FunctionReference<'query'>,
|
||||
{}
|
||||
)) as { _id?: Id<'usuarios'> } | null;
|
||||
|
||||
if (usuario && usuario._id) {
|
||||
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
|
||||
usuarioId: usuario._id,
|
||||
matriculaOuEmail: matricula.trim(),
|
||||
sucesso: true,
|
||||
userAgent: userAgent,
|
||||
ipAddress: ipPublico,
|
||||
latitudeGPS: localizacaoGPS.latitude,
|
||||
longitudeGPS: localizacaoGPS.longitude,
|
||||
precisaoGPS: localizacaoGPS.precisao,
|
||||
enderecoGPS: localizacaoGPS.endereco,
|
||||
cidadeGPS: localizacaoGPS.cidade,
|
||||
estadoGPS: localizacaoGPS.estado,
|
||||
paisGPS: localizacaoGPS.pais
|
||||
});
|
||||
} else {
|
||||
// Se não encontrou o usuário, registrar sem usuarioId (será atualizado depois)
|
||||
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
|
||||
matriculaOuEmail: matricula.trim(),
|
||||
sucesso: true,
|
||||
userAgent: userAgent,
|
||||
ipAddress: ipPublico,
|
||||
latitudeGPS: localizacaoGPS.latitude,
|
||||
longitudeGPS: localizacaoGPS.longitude,
|
||||
precisaoGPS: localizacaoGPS.precisao,
|
||||
enderecoGPS: localizacaoGPS.endereco,
|
||||
cidadeGPS: localizacaoGPS.cidade,
|
||||
estadoGPS: localizacaoGPS.estado,
|
||||
paisGPS: localizacaoGPS.pais
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao registrar tentativa de login:', err);
|
||||
// Não bloquear o login se houver erro ao registrar
|
||||
}
|
||||
})();
|
||||
|
||||
closeLoginModal();
|
||||
goto(resolve('/'));
|
||||
} else {
|
||||
erroLogin = 'Erro ao fazer login';
|
||||
}
|
||||
carregandoLogin = false;
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
const result = await authClient.signOut();
|
||||
if (result.error) {
|
||||
console.error('Sign out error:', result.error);
|
||||
}
|
||||
// Resetar tema para padrão ao fazer logout
|
||||
const { aplicarTemaPadrao } = await import('$lib/utils/temas');
|
||||
aplicarTemaPadrao();
|
||||
goto(resolve('/'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Header Fixo acima de tudo -->
|
||||
<!-- Header Fixo Minimalista & Premium -->
|
||||
<div
|
||||
class="navbar border-primary/10 from-primary/10 via-primary/5 to-primary/10 fixed top-0 right-0 left-0 z-50 h-16 border-b bg-linear-to-r px-4 shadow-sm"
|
||||
<nav
|
||||
class="menu text-base-content bg-base-200 border-base-100 h-[calc(100vh-64px)] w-full flex-col gap-2 overflow-y-auto p-4"
|
||||
>
|
||||
<div class="flex-none lg:hidden">
|
||||
<label for="my-drawer-3" class="btn btn-square btn-ghost btn-sm" aria-label="Abrir menu">
|
||||
<Menu class="h-5 w-5" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center gap-4 lg:gap-6">
|
||||
<!-- Logo Visível e Ajustado -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative h-10 overflow-hidden rounded-lg shadow-sm lg:h-14">
|
||||
<img src={logo} alt="Logo do Governo de PE" class="h-full w-full object-contain p-1" />
|
||||
</div>
|
||||
<div class="flex flex-col justify-center">
|
||||
<h1 class="text-base-content text-xl leading-none font-bold tracking-tight lg:text-2xl">
|
||||
SGSE
|
||||
</h1>
|
||||
<span class="text-base-content/60 font-medium tracking-wider uppercase"
|
||||
>Secretaria de Esportes</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto flex flex-none items-center gap-3 lg:gap-5">
|
||||
{#if currentUser.data}
|
||||
<!-- Nome e Perfil -->
|
||||
<div class="hidden flex-col items-end lg:flex">
|
||||
<span class="text-base-content text-sm leading-tight font-semibold"
|
||||
>{currentUser.data.nome}</span
|
||||
>
|
||||
<span class="text-base-content/60 text-xs leading-tight">{currentUser.data.role?.nome}</span
|
||||
>
|
||||
</div>
|
||||
{#snippet menuItem(item: MenuItem)}
|
||||
{@const Icon = getIconComponent(item.icon)}
|
||||
{@const isActive = isRouteActive(item.link, {
|
||||
exact: item.link === '/',
|
||||
excludePaths: item.excludePaths
|
||||
})}
|
||||
{@const hasSubmenus = item.submenus && item.submenus.length > 0}
|
||||
|
||||
<div class="dropdown dropdown-end">
|
||||
<!-- Botão de Perfil com Avatar -->
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class="btn avatar ring-base-200 hover:ring-primary/50 h-12 w-12 p-0 ring-2 ring-offset-2 transition-all"
|
||||
aria-label="Menu do usuário"
|
||||
>
|
||||
<div class="h-full w-full overflow-hidden rounded-full">
|
||||
{#if avatarUrlDoUsuario}
|
||||
<img
|
||||
src={avatarUrlDoUsuario}
|
||||
alt={currentUser.data?.nome || 'Usuário'}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-primary/10 text-primary flex h-full w-full items-center justify-center"
|
||||
>
|
||||
<User class="h-6 w-6" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box ring-base-content/5 z-1 mt-3 w-56 p-2 shadow-xl ring-1"
|
||||
>
|
||||
<li class="menu-title border-base-200 mb-2 border-b px-4 py-2">
|
||||
<span class="text-base-content font-bold">{currentUser.data?.nome}</span>
|
||||
<span class="text-base-content/60 text-xs font-normal">{currentUser.data.email}</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href={resolve('/perfil')} class="active:bg-primary/10 active:text-primary"
|
||||
><UserCog class="mr-2 h-4 w-4" /> Meu Perfil</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href={resolve('/alterar-senha')} class="active:bg-primary/10 active:text-primary"
|
||||
><Settings class="mr-2 h-4 w-4" /> Alterar Senha</a
|
||||
>
|
||||
</li>
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button type="button" onclick={handleLogout} class="text-error hover:bg-error/10"
|
||||
><LogIn class="mr-2 h-4 w-4 rotate-180" /> Sair</button
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Sino de notificações -->
|
||||
<div class="relative">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm rounded-full px-6"
|
||||
onclick={() => openLoginModal()}
|
||||
>
|
||||
Entrar
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer lg:drawer-open" style="margin-top: 64px;">
|
||||
<input id="my-drawer-3" type="checkbox" class="drawer-toggle" />
|
||||
<div
|
||||
class="drawer-content flex flex-col transition-all duration-300 lg:ml-72"
|
||||
style="min-height: calc(100vh - 64px);"
|
||||
>
|
||||
<!-- Page content -->
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<!-- Footer Minimalista -->
|
||||
<footer
|
||||
class="footer footer-center text-base-content/60 border-base-200 bg-base-100/50 border-t p-4 text-xs backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex flex-wrap justify-center gap-6">
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-primary font-medium transition-colors"
|
||||
onclick={() => openAboutModal()}>Sobre</button
|
||||
>
|
||||
<a href={resolve('/')} class="hover:text-primary font-medium transition-colors">Contato</a>
|
||||
<a href={resolve('/abrir-chamado')} class="hover:text-primary font-medium transition-colors"
|
||||
>Suporte</a
|
||||
>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a
|
||||
href={resolve('/abrir-chamado')}
|
||||
class="link link-hover hover:text-primary transition-colors">Suporte</a
|
||||
>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a
|
||||
href={resolve('/privacidade')}
|
||||
class="link link-hover hover:text-primary transition-colors">Privacidade</a
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
<p>© {new Date().getFullYear()} Governo de Pernambuco - Secretaria de Esportes</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<div class="drawer-side fixed z-40" style="margin-top: 64px;">
|
||||
<label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<div
|
||||
class="menu text-base-content border-base-300 from-primary/10 via-primary/5 to-primary/10 h-[calc(100vh-64px)] w-72 flex-col gap-2 overflow-y-auto border-r-2 bg-linear-to-b p-4 backdrop-blur-sm"
|
||||
>
|
||||
<!-- Sidebar menu items -->
|
||||
<!-- Sidebar menu items -->
|
||||
{#snippet menuItem(item: MenuItem)}
|
||||
{@const Icon = getIconComponent(item.icon)}
|
||||
{@const isActive = isRouteActive(item.link, {
|
||||
exact: item.link === '/',
|
||||
excludePaths: item.excludePaths
|
||||
})}
|
||||
{@const hasSubmenus = item.submenus && item.submenus.length > 0}
|
||||
|
||||
<li class="mb-1">
|
||||
{#if hasSubmenus}
|
||||
<details open={isActive} class="group/details">
|
||||
<summary
|
||||
class="{getMenuClasses(
|
||||
isActive,
|
||||
false,
|
||||
true
|
||||
)} cursor-pointer list-none justify-between [&::-webkit-details-marker]:hidden"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon class="h-5 w-5" strokeWidth={2} />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 opacity-50 transition-transform duration-200 group-open/details:rotate-180"
|
||||
/>
|
||||
</summary>
|
||||
<ul class="border-base-200 mt-1 ml-4 space-y-1 pl-2">
|
||||
{#if item.submenus}
|
||||
{#each item.submenus as sub (sub.link)}
|
||||
{@const isSubActive = isRouteActive(sub.link, {
|
||||
excludePaths: sub.excludePaths,
|
||||
exact: sub.exact
|
||||
})}
|
||||
<li>
|
||||
<a href={resolve(sub.link)} class={getMenuClasses(isSubActive, true)}>
|
||||
<span>{sub.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
</details>
|
||||
{:else}
|
||||
<a
|
||||
href={resolve(item.link)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
class={getMenuClasses(isActive)}
|
||||
>
|
||||
<li class="mb-1">
|
||||
{#if hasSubmenus}
|
||||
<details open={isActive} class="group/details">
|
||||
<summary
|
||||
class="{getMenuClasses(
|
||||
isActive,
|
||||
false,
|
||||
true
|
||||
)} cursor-pointer list-none justify-between [&::-webkit-details-marker]:hidden"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon class="h-5 w-5" strokeWidth={2} />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/snippet}
|
||||
|
||||
<ul class="menu w-full flex-1 p-0 px-2">
|
||||
{#if permissionsQuery.isLoading}
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
{#each Array(5)}
|
||||
<div class="skeleton h-12 w-full rounded-lg"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{#each menuItems as item (item.link)}
|
||||
{@render menuItem(item)}
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
|
||||
<ul class="menu mt-auto w-full p-0 px-2">
|
||||
<div class="divider before:bg-base-300 after:bg-base-300 my-2 px-2"></div>
|
||||
|
||||
<li class="px-2">
|
||||
<a
|
||||
href={resolve('/abrir-chamado')}
|
||||
class={getSolicitarClasses(currentPath === '/abrir-chamado')}
|
||||
>
|
||||
<UserPlus class="h-5 w-5" strokeWidth={2} />
|
||||
<span>Abrir Chamado</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Login -->
|
||||
{#if loginModalStore.showModal}
|
||||
<dialog class="modal modal-open">
|
||||
<div
|
||||
class="modal-box from-base-100 via-base-100 to-primary/5 relative max-w-md overflow-hidden bg-linear-to-br shadow-2xl backdrop-blur-sm"
|
||||
>
|
||||
<!-- Botão de fechar moderno -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost hover:bg-error/20 hover:text-error absolute top-4 right-4 z-10 transition-all duration-200"
|
||||
onclick={closeLoginModal}
|
||||
aria-label="Fechar modal"
|
||||
>
|
||||
<XCircle class="h-5 w-5" strokeWidth={2.5} />
|
||||
</button>
|
||||
|
||||
<!-- Decoração de fundo -->
|
||||
<div class="bg-primary/10 absolute -top-20 -right-20 h-40 w-40 rounded-full blur-3xl"></div>
|
||||
<div class="bg-primary/5 absolute -bottom-20 -left-20 h-40 w-40 rounded-full blur-3xl"></div>
|
||||
|
||||
<div class="relative z-10 p-8">
|
||||
<!-- Header com logo e título -->
|
||||
<div class="mb-8 text-center">
|
||||
<div class="avatar mx-auto mb-5">
|
||||
<div
|
||||
class="group ring-primary/20 relative w-24 overflow-hidden rounded-2xl bg-white p-4 shadow-xl ring-2 transition-all duration-300 hover:scale-105 hover:shadow-2xl"
|
||||
>
|
||||
<div
|
||||
class="from-primary/10 absolute inset-0 bg-linear-to-br to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
></div>
|
||||
<img src={logo} alt="Logo SGSE" class="relative z-10 h-full w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-primary mb-2 text-4xl font-bold tracking-tight">Login</h3>
|
||||
<p class="text-base-content/70 text-sm font-medium">
|
||||
Acesse o sistema com suas credenciais
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mensagem de erro -->
|
||||
{#if erroLogin}
|
||||
<div
|
||||
class="alert alert-error border-error/30 bg-error/10 mb-6 shadow-lg backdrop-blur-sm"
|
||||
>
|
||||
<XCircle class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2.5} />
|
||||
<span class="font-medium">{erroLogin}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Formulário -->
|
||||
<form class="space-y-5" onsubmit={handleLogin}>
|
||||
<!-- Campo Matrícula/E-mail -->
|
||||
<div class="form-control">
|
||||
<label class="label pb-2" for="login-matricula">
|
||||
<span class="text-primary label-text text-sm font-semibold">Matrícula ou E-mail</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="login-matricula"
|
||||
type="text"
|
||||
placeholder="Digite sua matrícula ou e-mail"
|
||||
class="input input-bordered input-primary focus:border-primary focus:shadow-primary/20 w-full border-2 transition-all duration-200 focus:shadow-lg disabled:opacity-50"
|
||||
bind:value={matricula}
|
||||
required
|
||||
disabled={carregandoLogin}
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campo Senha -->
|
||||
<div class="form-control">
|
||||
<label class="label pb-2" for="login-password">
|
||||
<span class="text-primary label-text text-sm font-semibold">Senha</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
placeholder="Digite sua senha"
|
||||
class="input input-bordered input-primary focus:border-primary focus:shadow-primary/20 w-full border-2 transition-all duration-200 focus:shadow-lg disabled:opacity-50"
|
||||
bind:value={senha}
|
||||
required
|
||||
disabled={carregandoLogin}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botão de submit -->
|
||||
<div class="form-control pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-lg group from-primary via-primary to-primary/90 relative w-full overflow-hidden border-0 bg-linear-to-r shadow-xl transition-all duration-300 hover:scale-[1.02] hover:shadow-2xl disabled:opacity-50"
|
||||
disabled={carregandoLogin}
|
||||
>
|
||||
<!-- Efeito de brilho animado -->
|
||||
<div
|
||||
class="absolute inset-0 -translate-x-full bg-linear-to-r from-transparent via-white/30 to-transparent transition-transform duration-1000 group-hover:translate-x-full"
|
||||
></div>
|
||||
|
||||
{#if carregandoLogin}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="font-semibold">Entrando...</span>
|
||||
{:else}
|
||||
<LogIn
|
||||
class="h-5 w-5 transition-transform duration-300 group-hover:scale-110"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<span class="font-semibold">Entrar</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Links auxiliares -->
|
||||
<div class="space-y-3 pt-4 text-center">
|
||||
<a
|
||||
href={resolve('/abrir-chamado')}
|
||||
class="link link-primary block text-sm font-medium transition-all duration-200 hover:scale-105"
|
||||
onclick={closeLoginModal}
|
||||
>
|
||||
Abrir Chamado
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/esqueci-senha')}
|
||||
class="link link-secondary block text-sm font-medium transition-all duration-200 hover:scale-105"
|
||||
onclick={closeLoginModal}
|
||||
>
|
||||
Esqueceu sua senha?
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<form method="dialog" class="modal-backdrop" onclick={closeLoginModal}>
|
||||
<button type="button">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
<!-- Modal Sobre -->
|
||||
{#if showAboutModal}
|
||||
<dialog class="modal modal-open">
|
||||
<div
|
||||
class="modal-box from-base-100 to-base-200 relative max-w-md overflow-hidden bg-linear-to-br shadow-xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 absolute top-2 right-2 z-10"
|
||||
onclick={closeAboutModal}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div class="space-y-5 px-6 py-6 text-center">
|
||||
<!-- Logo e Título -->
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="avatar">
|
||||
<div class="ring-primary/20 w-20 rounded-xl bg-white p-3 shadow-lg ring-2">
|
||||
<img src={logo} alt="Logo SGSE" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-primary text-2xl font-bold tracking-tight">SGSE</h3>
|
||||
<p class="text-base-content/70 text-sm font-medium">
|
||||
Sistema de Gerenciamento de Secretaria
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider my-1"></div>
|
||||
|
||||
<!-- Informações de Versão -->
|
||||
<div
|
||||
class="from-primary/10 to-primary/5 border-primary/10 space-y-2 rounded-xl border bg-linear-to-br p-4 shadow-sm"
|
||||
<ChevronDown
|
||||
class="h-4 w-4 opacity-50 transition-transform duration-200 group-open/details:rotate-180"
|
||||
/>
|
||||
</summary>
|
||||
<ul class="border-base-200 mt-1 ml-4 space-y-1 pl-2">
|
||||
{#if item.submenus}
|
||||
{#each item.submenus as sub (sub.link)}
|
||||
{@const isSubActive = isRouteActive(sub.link, {
|
||||
excludePaths: sub.excludePaths,
|
||||
exact: sub.exact
|
||||
})}
|
||||
<li>
|
||||
<a
|
||||
href={resolve(sub.link as any)}
|
||||
class={getMenuClasses(isSubActive, true)}
|
||||
onclick={() => onNavigate?.()}
|
||||
>
|
||||
<span>{sub.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
</details>
|
||||
{:else}
|
||||
<a
|
||||
href={resolve(item.link as any)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
class={getMenuClasses(isActive)}
|
||||
onclick={() => onNavigate?.()}
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Tag class="text-primary h-4 w-4" strokeWidth={2} />
|
||||
<p class="text-base-content/60 text-xs font-medium tracking-wide uppercase">Versão</p>
|
||||
</div>
|
||||
<p class="text-primary text-2xl font-bold tracking-tight">1.0 11_2025</p>
|
||||
<div class="badge badge-warning badge-sm gap-1.5 px-3 py-1.5 text-xs">
|
||||
<Plus class="h-3.5 w-3.5" strokeWidth={2} />
|
||||
Em Desenvolvimento
|
||||
</div>
|
||||
</div>
|
||||
<Icon class="h-5 w-5" strokeWidth={2} />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/snippet}
|
||||
|
||||
<!-- Desenvolvido por -->
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-base-content/50 text-xs font-medium tracking-wide uppercase">
|
||||
Desenvolvido por
|
||||
</p>
|
||||
<p class="text-primary text-sm font-semibold">Secretaria de Esportes de Pernambuco</p>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider my-1"></div>
|
||||
|
||||
<!-- Informações Adicionais -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div
|
||||
class="bg-base-200/60 border-base-300/50 rounded-lg border p-3 shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
<p class="text-primary mb-1 text-xs font-semibold tracking-wide uppercase">Governo</p>
|
||||
<p class="text-base-content/60 text-xs font-medium">Estado de Pernambuco</p>
|
||||
</div>
|
||||
<div
|
||||
class="bg-base-200/60 border-base-300/50 rounded-lg border p-3 shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
<p class="text-primary mb-1 text-xs font-semibold tracking-wide uppercase">Ano</p>
|
||||
<p class="text-base-content/60 text-xs font-medium">2025</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botão OK -->
|
||||
<div class="pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm mx-auto w-full max-w-xs shadow-md transition-all duration-200 hover:shadow-lg"
|
||||
onclick={closeAboutModal}
|
||||
>
|
||||
<Check class="h-4 w-4" strokeWidth={2} />
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
<ul class="menu w-full flex-1 p-0 px-2">
|
||||
{#if permissionsQuery.isLoading}
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
{#each Array(5)}
|
||||
<div class="skeleton h-12 w-full rounded-lg"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
onclick={closeAboutModal}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === 'Escape' && closeAboutModal()}
|
||||
></div>
|
||||
</dialog>
|
||||
{/if}
|
||||
{:else}
|
||||
{#each menuItems as item (item.link)}
|
||||
{@render menuItem(item)}
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
|
||||
<!-- Componentes de Chat (apenas se autenticado) -->
|
||||
{#if currentUser.data}
|
||||
<PresenceManager />
|
||||
<ChatWidget />
|
||||
{/if}
|
||||
<ul class="menu mt-auto w-full p-0 px-2">
|
||||
<div class="divider before:bg-base-300 after:bg-base-300 my-2 px-2"></div>
|
||||
|
||||
<li class="px-2">
|
||||
<a
|
||||
href={resolve('/abrir-chamado')}
|
||||
class={getSolicitarClasses(currentPath === '/abrir-chamado')}
|
||||
onclick={() => onNavigate?.()}
|
||||
>
|
||||
<UserPlus class="h-5 w-5" strokeWidth={2} />
|
||||
<span>Abrir Chamado</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
/* Animação de pulso sutil para o anel do botão de perfil */
|
||||
@keyframes pulse-ring-subtle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Animação de pulso para o badge de status online */
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove default details marker */
|
||||
details > summary {
|
||||
list-style: none;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import type { FunctionReference } from 'convex/server';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { LogIn, Settings, User, UserCog } from 'lucide-svelte';
|
||||
import { authClient } from '$lib/auth';
|
||||
import NotificationBell from '$lib/components/chat/NotificationBell.svelte';
|
||||
|
||||
let currentPath = $derived(page.url.pathname);
|
||||
|
||||
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>, {});
|
||||
|
||||
// Função para obter a URL do avatar/foto do usuário
|
||||
let avatarUrlDoUsuario = $derived.by(() => {
|
||||
if (!currentUser.data) return null;
|
||||
|
||||
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
|
||||
if (currentUser.data.fotoPerfilUrl) {
|
||||
return currentUser.data.fotoPerfilUrl;
|
||||
}
|
||||
|
||||
if (currentUser.data.avatar) {
|
||||
return currentUser.data.avatar;
|
||||
}
|
||||
|
||||
// Fallback: retornar null para usar o ícone User do Lucide
|
||||
return null;
|
||||
});
|
||||
|
||||
function goToLogin(redirectTo?: string) {
|
||||
const target = redirectTo || currentPath || '/';
|
||||
goto(`${resolve('/login')}?redirect=${encodeURIComponent(target)}`);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
const result = await authClient.signOut();
|
||||
if (result.error) {
|
||||
console.error('Sign out error:', result.error);
|
||||
}
|
||||
// Resetar tema para padrão ao fazer logout
|
||||
const { aplicarTemaPadrao } = await import('$lib/utils/temas');
|
||||
aplicarTemaPadrao();
|
||||
goto(resolve('/home'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
{#if currentUser.data}
|
||||
<!-- Nome e Perfil -->
|
||||
<div class="hidden flex-col items-end lg:flex">
|
||||
<span class="text-base-content text-sm leading-tight font-semibold"
|
||||
>{currentUser.data.nome}</span
|
||||
>
|
||||
<span class="text-base-content/60 text-xs leading-tight">{currentUser.data.role?.nome}</span>
|
||||
</div>
|
||||
|
||||
<div class="dropdown dropdown-end">
|
||||
<!-- Botão de Perfil com Avatar -->
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class="btn avatar ring-base-200 hover:ring-primary/50 h-10 w-10 p-0 ring-2 ring-offset-2 transition-all"
|
||||
aria-label="Menu do usuário"
|
||||
>
|
||||
<div class="h-full w-full overflow-hidden rounded-full">
|
||||
{#if avatarUrlDoUsuario}
|
||||
<img
|
||||
src={avatarUrlDoUsuario}
|
||||
alt={currentUser.data?.nome || 'Usuário'}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-primary/10 text-primary flex h-full w-full items-center justify-center">
|
||||
<User class="h-5 w-5" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box ring-base-content/5 z-1 mt-3 w-56 p-2 shadow-xl ring-1"
|
||||
>
|
||||
<li class="menu-title border-base-200 mb-2 border-b px-4 py-2">
|
||||
<span class="text-base-content font-bold">{currentUser.data?.nome}</span>
|
||||
<span class="text-base-content/60 text-xs font-normal">{currentUser.data.email}</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href={resolve('/perfil')} class="active:bg-primary/10 active:text-primary"
|
||||
><UserCog class="mr-2 h-4 w-4" /> Meu Perfil</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href={resolve('/alterar-senha')} class="active:bg-primary/10 active:text-primary"
|
||||
><Settings class="mr-2 h-4 w-4" /> Alterar Senha</a
|
||||
>
|
||||
</li>
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button type="button" onclick={handleLogout} class="text-error hover:bg-error/10"
|
||||
><LogIn class="mr-2 h-4 w-4 rotate-180" /> Sair</button
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Sino de notificações -->
|
||||
<div class="relative">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm rounded-full px-6"
|
||||
onclick={() => goToLogin()}
|
||||
>
|
||||
Entrar
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
62
apps/web/src/lib/components/login/LoginInput.svelte
Normal file
62
apps/web/src/lib/components/login/LoginInput.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { Field } from '@ark-ui/svelte/field';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
autocomplete?: HTMLInputAttributes['autocomplete'];
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
error?: string | null;
|
||||
right?: Snippet;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
label,
|
||||
type = 'text',
|
||||
placeholder = '',
|
||||
autocomplete,
|
||||
disabled = false,
|
||||
required = false,
|
||||
error = null,
|
||||
right,
|
||||
value = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
const invalid = $derived(!!error);
|
||||
</script>
|
||||
|
||||
<Field.Root {invalid} {required} class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Field.Label
|
||||
for={id}
|
||||
class="text-base-content/60 text-xs font-semibold tracking-wider uppercase"
|
||||
>
|
||||
{label}
|
||||
</Field.Label>
|
||||
{@render right?.()}
|
||||
</div>
|
||||
|
||||
<div class="group relative">
|
||||
<Field.Input
|
||||
{id}
|
||||
{type}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{autocomplete}
|
||||
{required}
|
||||
bind:value
|
||||
class="border-base-content/10 bg-base-200/25 text-base-content placeholder-base-content/40 focus:border-primary/50 focus:bg-base-200/35 focus:ring-primary/20 w-full rounded-xl border px-4 py-3 transition-all duration-300 focus:ring-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<Field.ErrorText class="text-error text-sm font-medium">{error}</Field.ErrorText>
|
||||
{/if}
|
||||
</Field.Root>
|
||||
@@ -153,20 +153,15 @@ export function aplicarTema(temaId: TemaId | string | null | undefined): void {
|
||||
|
||||
const nomeDaisyUI = obterNomeDaisyUI(temaId || 'purple');
|
||||
const htmlElement = document.documentElement;
|
||||
const bodyElement = document.body;
|
||||
|
||||
if (htmlElement) {
|
||||
// Remover todos os atributos data-theme existentes primeiro
|
||||
htmlElement.removeAttribute('data-theme');
|
||||
if (bodyElement) {
|
||||
bodyElement.removeAttribute('data-theme');
|
||||
}
|
||||
// Evita que `body[data-theme]` sobrescreva o tema do `<html>`
|
||||
if (document.body) document.body.removeAttribute('data-theme');
|
||||
|
||||
// Aplicar o novo tema
|
||||
htmlElement.setAttribute('data-theme', nomeDaisyUI);
|
||||
if (bodyElement) {
|
||||
bodyElement.setAttribute('data-theme', nomeDaisyUI);
|
||||
}
|
||||
|
||||
// Forçar reflow para garantir que o CSS seja aplicado
|
||||
void htmlElement.offsetHeight;
|
||||
@@ -230,20 +225,21 @@ export function obterCoresDoTema(): {
|
||||
}
|
||||
|
||||
const htmlElement = document.documentElement;
|
||||
const getComputedStyle = (varName: string): string => {
|
||||
return getComputedStyle(htmlElement).getPropertyValue(varName).trim() || '';
|
||||
const readCssVar = (varName: string): string => {
|
||||
return window.getComputedStyle(htmlElement).getPropertyValue(varName).trim() || '';
|
||||
};
|
||||
|
||||
// Tentar obter variáveis CSS do DaisyUI
|
||||
const primary = getComputedStyle('--p') || '#667eea';
|
||||
const success = getComputedStyle('--suc') || '#10b981';
|
||||
const error = getComputedStyle('--er') || '#ef4444';
|
||||
const warning = getComputedStyle('--wa') || '#f59e0b';
|
||||
const info = getComputedStyle('--in') || '#3b82f6';
|
||||
const baseContent = getComputedStyle('--bc') || '#1f2937';
|
||||
const base100 = getComputedStyle('--b1') || '#ffffff';
|
||||
const base200 = getComputedStyle('--b2') || '#f3f4f6';
|
||||
const base300 = getComputedStyle('--b3') || '#e5e7eb';
|
||||
// DaisyUI v5: variáveis `--color-*`
|
||||
// Fallback para v4/legado: variáveis curtas `--p`, `--suc`, etc.
|
||||
const primary = readCssVar('--color-primary') || readCssVar('--p') || '#667eea';
|
||||
const success = readCssVar('--color-success') || readCssVar('--suc') || '#10b981';
|
||||
const error = readCssVar('--color-error') || readCssVar('--er') || '#ef4444';
|
||||
const warning = readCssVar('--color-warning') || readCssVar('--wa') || '#f59e0b';
|
||||
const info = readCssVar('--color-info') || readCssVar('--in') || '#3b82f6';
|
||||
const baseContent = readCssVar('--color-base-content') || readCssVar('--bc') || '#1f2937';
|
||||
const base100 = readCssVar('--color-base-100') || readCssVar('--b1') || '#ffffff';
|
||||
const base200 = readCssVar('--color-base-200') || readCssVar('--b2') || '#f3f4f6';
|
||||
const base300 = readCssVar('--color-base-300') || readCssVar('--b3') || '#e5e7eb';
|
||||
|
||||
return {
|
||||
primary: primary || '#667eea',
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { FunctionReference } from 'convex/server';
|
||||
|
||||
export const load = async ({ locals }) => {
|
||||
export const load = async ({ locals, url }) => {
|
||||
if (!locals.token) {
|
||||
throw redirect(302, '/login?redirect=' + url.pathname);
|
||||
}
|
||||
try {
|
||||
const client = createConvexHttpClient({ token: locals.token });
|
||||
const currentUser = await client.query(api.auth.getCurrentUser, {});
|
||||
const currentUser = await client.query(api.auth.getCurrentUser as FunctionReference<'query'>);
|
||||
|
||||
if (!currentUser) {
|
||||
throw redirect(302, '/login?redirect=' + url.pathname);
|
||||
}
|
||||
return { currentUser };
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar usuário atual no layout do dashboard:', error);
|
||||
// Evita quebrar toda a área logada em caso de falha transitória na API/auth
|
||||
return { currentUser: null };
|
||||
} catch {
|
||||
return error(500, 'Ops! Ocorreu um erro, tente novamente mais tarde.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,103 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import ActionGuard from '$lib/components/ActionGuard.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
import PushNotificationManager from '$lib/components/PushNotificationManager.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import MenuToggleIcon from '$lib/components/MenuToggleIcon.svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import DashboardHeaderActions from '$lib/components/dashboard/DashboardHeaderActions.svelte';
|
||||
import ChatWidget from '$lib/components/chat/ChatWidget.svelte';
|
||||
import PresenceManager from '$lib/components/chat/PresenceManager.svelte';
|
||||
|
||||
const { children } = $props();
|
||||
|
||||
// Usuário atual e consentimento LGPD
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
const consentimentoLGPD = useQuery(api.lgpd.verificarConsentimento, { tipo: 'termo_uso' });
|
||||
let sidebarOpen = $state(false);
|
||||
let isDesktop = $state(false);
|
||||
const toggleSidebar = () => (sidebarOpen = !sidebarOpen);
|
||||
const closeSidebar = () => (sidebarOpen = false);
|
||||
|
||||
// Redirecionar para o termo de consentimento se obrigatório e não aceito
|
||||
$effect(() => {
|
||||
const p = page.url.pathname;
|
||||
// No desktop, abrir por padrão; no mobile, começar fechado
|
||||
onMount(() => {
|
||||
const mql = window.matchMedia('(min-width: 1024px)');
|
||||
|
||||
// Rotas públicas/que não exigem termo
|
||||
if (
|
||||
p === '/' ||
|
||||
p === '/abrir-chamado' ||
|
||||
p === '/termo-consentimento' ||
|
||||
p.startsWith('/privacidade') ||
|
||||
p.startsWith('/api/')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
isDesktop = mql.matches;
|
||||
};
|
||||
|
||||
// Precisa estar autenticado para exigir LGPD
|
||||
if (!currentUser?.data) {
|
||||
return;
|
||||
}
|
||||
update();
|
||||
sidebarOpen = mql.matches;
|
||||
|
||||
// Query ainda carregando ou sem dados
|
||||
if (!consentimentoLGPD?.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = consentimentoLGPD.data;
|
||||
|
||||
if (data.termoObrigatorio && !data.aceito) {
|
||||
const redirect = encodeURIComponent(p);
|
||||
window.location.href = `/termo-consentimento?redirect=${redirect}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Resolver recurso/ação a partir da rota
|
||||
const routeAction = $derived.by(() => {
|
||||
const p = page.url.pathname;
|
||||
if (p === '/' || p === '/abrir-chamado') return null;
|
||||
|
||||
// Funcionários
|
||||
if (p.startsWith('/recursos-humanos/funcionarios')) {
|
||||
if (p.includes('/cadastro')) return { recurso: 'funcionarios', acao: 'criar' };
|
||||
if (p.includes('/excluir')) return { recurso: 'funcionarios', acao: 'excluir' };
|
||||
if (p.includes('/editar') || p.includes('/funcionarioId'))
|
||||
return { recurso: 'funcionarios', acao: 'editar' };
|
||||
return { recurso: 'funcionarios', acao: 'listar' };
|
||||
}
|
||||
|
||||
// Símbolos
|
||||
if (p.startsWith('/recursos-humanos/simbolos')) {
|
||||
if (p.includes('/cadastro')) return { recurso: 'simbolos', acao: 'criar' };
|
||||
if (p.includes('/excluir')) return { recurso: 'simbolos', acao: 'excluir' };
|
||||
if (p.includes('/editar') || p.includes('/simboloId'))
|
||||
return { recurso: 'simbolos', acao: 'editar' };
|
||||
return { recurso: 'simbolos', acao: 'listar' };
|
||||
}
|
||||
|
||||
// Outras áreas (uso genérico: ver)
|
||||
if (p.startsWith('/financeiro')) return { recurso: 'financeiro', acao: 'ver' };
|
||||
if (p.startsWith('/controladoria')) return { recurso: 'controladoria', acao: 'ver' };
|
||||
if (p.startsWith('/licitacoes')) return { recurso: 'licitacoes', acao: 'ver' };
|
||||
if (p.startsWith('/compras')) return { recurso: 'compras', acao: 'ver' };
|
||||
if (p.startsWith('/juridico')) return { recurso: 'juridico', acao: 'ver' };
|
||||
if (p.startsWith('/comunicacao')) return { recurso: 'comunicacao', acao: 'ver' };
|
||||
if (p.startsWith('/programas-esportivos'))
|
||||
return { recurso: 'programas_esportivos', acao: 'ver' };
|
||||
if (p.startsWith('/secretaria-executiva'))
|
||||
return { recurso: 'secretaria_executiva', acao: 'ver' };
|
||||
if (p.startsWith('/gestao-pessoas')) return { recurso: 'gestao_pessoas', acao: 'ver' };
|
||||
|
||||
return null;
|
||||
mql.addEventListener('change', update);
|
||||
return () => mql.removeEventListener('change', update);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if routeAction}
|
||||
<ActionGuard recurso={routeAction.recurso} acao={routeAction.acao}>
|
||||
<main id="container-central" class="w-full max-w-none px-3 py-4 lg:px-4">
|
||||
{@render children()}
|
||||
</main>
|
||||
</ActionGuard>
|
||||
{:else}
|
||||
<main id="container-central" class="w-full max-w-none px-3 py-4 lg:px-4">
|
||||
{@render children()}
|
||||
</main>
|
||||
{/if}
|
||||
<div
|
||||
class="bg-base-100 text-base-content selection:bg-primary selection:text-primary-content flex min-h-screen flex-col font-sans"
|
||||
>
|
||||
<Header>
|
||||
{#snippet left()}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
aria-label={sidebarOpen ? 'Fechar menu' : 'Abrir menu'}
|
||||
onclick={toggleSidebar}
|
||||
>
|
||||
<MenuToggleIcon open={sidebarOpen} class="h-5 w-5" />
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet right()}
|
||||
<DashboardHeaderActions />
|
||||
{/snippet}
|
||||
</Header>
|
||||
|
||||
<div class="relative flex min-h-[calc(100vh-4rem)] flex-1">
|
||||
<!-- Overlay (mobile) -->
|
||||
{#if sidebarOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/30 backdrop-blur-[1px] lg:hidden"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Fechar menu"
|
||||
onclick={closeSidebar}
|
||||
onkeydown={(e) => e.key === 'Escape' && closeSidebar()}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="bg-base-100 border-base-200 fixed top-16 bottom-0 left-0 z-50 w-72 border-r shadow-sm transition-transform duration-200"
|
||||
class:translate-x-0={sidebarOpen}
|
||||
class:-translate-x-full={!sidebarOpen}
|
||||
>
|
||||
<div class="h-full overflow-y-auto">
|
||||
<Sidebar onNavigate={() => !isDesktop && closeSidebar()} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col justify-between transition-[padding] duration-200 {sidebarOpen
|
||||
? 'lg:pl-72'
|
||||
: 'lg:pl-0'}"
|
||||
>
|
||||
<div id="container-central" class="w-full max-w-none px-3 py-4 lg:px-4">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Componentes de Chat (gerenciam auth internamente) -->
|
||||
<PresenceManager />
|
||||
<ChatWidget />
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications (Sonner) -->
|
||||
<Toaster position="top-right" richColors closeButton expand={true} />
|
||||
|
||||
<!-- Push Notification Manager (registra subscription automaticamente) -->
|
||||
<PushNotificationManager />
|
||||
|
||||
<style>
|
||||
/* Evita “corredor” quando páginas usam `container mx-auto` */
|
||||
#container-central :global(.container) {
|
||||
max-width: none !important;
|
||||
}
|
||||
#container-central :global(.container.mx-auto) {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { UserPlus, Mail, Clock, Award, TrendingUp, Zap, Users, Database } from 'lucide-svelte';
|
||||
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
|
||||
import { loginModalStore } from '$lib/stores/loginModal.svelte';
|
||||
|
||||
// Queries para dados do dashboard
|
||||
const statsQuery = useQuery(api.dashboard.getStats, {});
|
||||
@@ -36,7 +35,12 @@
|
||||
|
||||
// Se for erro de autenticação, abrir modal de login automaticamente
|
||||
if (error === 'auth_required') {
|
||||
loginModalStore.open(route || to.url.pathname);
|
||||
const redirectTo = route || to.url.pathname;
|
||||
goto(`${resolve('/login')}?redirect=${encodeURIComponent(redirectTo)}`, {
|
||||
replaceState: true,
|
||||
noScroll: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Limpar URL usando SvelteKit (após router estar inicializado)
|
||||
@@ -65,7 +69,12 @@
|
||||
const route = urlParams.get('route') || urlParams.get('redirect') || '';
|
||||
|
||||
if (error === 'auth_required') {
|
||||
loginModalStore.open(route || window.location.pathname);
|
||||
const redirectTo = route || window.location.pathname;
|
||||
goto(`${resolve('/login')}?redirect=${encodeURIComponent(redirectTo)}`, {
|
||||
replaceState: true,
|
||||
noScroll: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +140,7 @@
|
||||
</script>
|
||||
|
||||
<ProtectedRoute>
|
||||
<main class="container mx-auto px-4 py-4">
|
||||
<main class="w-full px-4 py-4">
|
||||
<!-- Alerta de Acesso Negado / Autenticação -->
|
||||
{#if showAlert}
|
||||
{@const alertData = getAlertMessage()}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
3
apps/web/src/routes/(dashboard)/compras/+page.server.ts
Normal file
3
apps/web/src/routes/(dashboard)/compras/+page.server.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import ActionGuard from '$lib/components/ActionGuard.svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -232,25 +231,23 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<ActionGuard recurso="setores" acao="criar">
|
||||
<button class="btn btn-primary shadow-lg" onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Novo Setor
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<button class="btn btn-primary shadow-lg" onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Novo Setor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -307,52 +304,48 @@
|
||||
<td class="text-base-content/60 text-sm">{formatDate(setor.createdAt)}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<ActionGuard recurso="setores" acao="editar">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => openEditModal(setor)}
|
||||
aria-label="Editar setor {setor.nome}"
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => openEditModal(setor)}
|
||||
aria-label="Editar setor {setor.nome}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<ActionGuard recurso="setores" acao="excluir">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
onclick={() => openDeleteModal(setor)}
|
||||
aria-label="Excluir setor {setor.nome}"
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
onclick={() => openDeleteModal(setor)}
|
||||
aria-label="Excluir setor {setor.nome}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
3
apps/web/src/routes/(dashboard)/fluxos/+page.server.ts
Normal file
3
apps/web/src/routes/(dashboard)/fluxos/+page.server.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import ActionGuard from '$lib/components/ActionGuard.svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -164,26 +163,24 @@
|
||||
<option value="archived">Arquivado</option>
|
||||
</select>
|
||||
|
||||
<ActionGuard recurso="fluxos_templates" acao="criar">
|
||||
<button class="btn btn-secondary shadow-lg" onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Novo Template
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<button class="btn btn-secondary shadow-lg" onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Novo Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -4,7 +4,6 @@
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import ActionGuard from '$lib/components/ActionGuard.svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
let instanceId = $derived($page.params.id as Id<'flowInstances'>);
|
||||
@@ -416,26 +415,24 @@
|
||||
</div>
|
||||
|
||||
{#if instance.status === 'active'}
|
||||
<ActionGuard recurso="fluxos_instancias" acao="cancelar">
|
||||
<button class="btn btn-error btn-outline" onclick={() => (showCancelModal = true)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Cancelar Fluxo
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<button class="btn btn-error btn-outline" onclick={() => (showCancelModal = true)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Cancelar Fluxo
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -616,67 +613,59 @@
|
||||
{#if instance.status === 'active'}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if step.status === 'pending'}
|
||||
<ActionGuard recurso="fluxos_instancias" acao="avancar_passo">
|
||||
<button
|
||||
class="btn btn-info btn-sm"
|
||||
onclick={() => handleStartStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Iniciar
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<button
|
||||
class="btn btn-info btn-sm"
|
||||
onclick={() => handleStartStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Iniciar
|
||||
</button>
|
||||
{:else if step.status === 'in_progress'}
|
||||
<ActionGuard recurso="fluxos_instancias" acao="avancar_passo">
|
||||
<button
|
||||
class="btn btn-success btn-sm"
|
||||
onclick={() => handleCompleteStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Concluir
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-warning btn-sm"
|
||||
onclick={() => handleBlockStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Bloquear
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<button
|
||||
class="btn btn-success btn-sm"
|
||||
onclick={() => handleCompleteStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Concluir
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-warning btn-sm"
|
||||
onclick={() => handleBlockStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Bloquear
|
||||
</button>
|
||||
{:else if step.status === 'blocked'}
|
||||
<ActionGuard recurso="fluxos_instancias" acao="avancar_passo">
|
||||
<button
|
||||
class="btn btn-info btn-sm"
|
||||
onclick={() => handleStartStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Desbloquear
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<button
|
||||
class="btn btn-info btn-sm"
|
||||
onclick={() => handleStartStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Desbloquear
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<ActionGuard recurso="fluxos_instancias" acao="atribuir_usuario">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => openReassignModal(step)}
|
||||
aria-label="Reatribuir responsável"
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => openReassignModal(step)}
|
||||
aria-label="Reatribuir responsável"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
@@ -700,29 +689,27 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<ActionGuard recurso="fluxos_documentos" acao="upload">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => openUploadModal(step)}
|
||||
aria-label="Upload de documento"
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => openUploadModal(step)}
|
||||
aria-label="Upload de documento"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -759,15 +746,13 @@
|
||||
/>
|
||||
</svg>
|
||||
{doc.name}
|
||||
<ActionGuard recurso="fluxos_documentos" acao="excluir">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={() => handleDeleteDocument(doc._id)}
|
||||
aria-label="Excluir documento {doc.name}"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={() => handleDeleteDocument(doc._id)}
|
||||
aria-label="Excluir documento {doc.name}"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import ActionGuard from '$lib/components/ActionGuard.svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -148,26 +147,24 @@
|
||||
<option value="cancelled">Cancelado</option>
|
||||
</select>
|
||||
|
||||
<ActionGuard recurso="fluxos_instancias" acao="criar">
|
||||
<button class="btn btn-info shadow-lg" onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Nova Instância
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<button class="btn btn-info shadow-lg" onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Nova Instância
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
3
apps/web/src/routes/(dashboard)/juridico/+page.server.ts
Normal file
3
apps/web/src/routes/(dashboard)/juridico/+page.server.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import ActionGuard from '$lib/components/ActionGuard.svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -148,26 +147,24 @@
|
||||
<option value="cancelled">Cancelado</option>
|
||||
</select>
|
||||
|
||||
<ActionGuard recurso="fluxos_instancias" acao="criar">
|
||||
<button class="btn btn-info shadow-lg" onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Novo Fluxo
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<button class="btn btn-info shadow-lg" onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Novo Fluxo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -5,7 +5,6 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import ActionGuard from '$lib/components/ActionGuard.svelte';
|
||||
import RelogioPrazo from '$lib/components/RelogioPrazo.svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
@@ -651,26 +650,24 @@
|
||||
</div>
|
||||
|
||||
{#if instance.status === 'active'}
|
||||
<ActionGuard recurso="fluxos_instancias" acao="cancelar">
|
||||
<button class="btn btn-error btn-outline" onclick={() => (showCancelModal = true)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Cancelar Fluxo
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<button class="btn btn-error btn-outline" onclick={() => (showCancelModal = true)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Cancelar Fluxo
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -867,68 +864,60 @@
|
||||
{#if instance.status === 'active'}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if step.status === 'pending'}
|
||||
<ActionGuard recurso="fluxos_instancias" acao="avancar_passo">
|
||||
<button
|
||||
class="btn btn-info btn-sm"
|
||||
onclick={() => handleStartStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Iniciar
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<button
|
||||
class="btn btn-info btn-sm"
|
||||
onclick={() => handleStartStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Iniciar
|
||||
</button>
|
||||
{:else if step.status === 'in_progress'}
|
||||
<ActionGuard recurso="fluxos_instancias" acao="avancar_passo">
|
||||
<button
|
||||
class="btn btn-success btn-sm"
|
||||
onclick={() => handleCompleteStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Concluir
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-warning btn-sm"
|
||||
onclick={() => handleBlockStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Bloquear
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<button
|
||||
class="btn btn-success btn-sm"
|
||||
onclick={() => handleCompleteStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Concluir
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-warning btn-sm"
|
||||
onclick={() => handleBlockStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Bloquear
|
||||
</button>
|
||||
{:else if step.status === 'blocked'}
|
||||
<ActionGuard recurso="fluxos_instancias" acao="avancar_passo">
|
||||
<button
|
||||
class="btn btn-info btn-sm"
|
||||
onclick={() => handleStartStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Desbloquear
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<button
|
||||
class="btn btn-info btn-sm"
|
||||
onclick={() => handleStartStep(step._id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Desbloquear
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<ActionGuard recurso="fluxos_instancias" acao="atribuir_usuario">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => openReassignModal(step)}
|
||||
aria-label="Reatribuir responsável"
|
||||
title="Reatribuir responsável"
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => openReassignModal(step)}
|
||||
aria-label="Reatribuir responsável"
|
||||
title="Reatribuir responsável"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1104,30 +1093,28 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if instance.status === 'active'}
|
||||
<ActionGuard recurso="fluxos_documentos" acao="upload">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => openUploadModal(step)}
|
||||
aria-label="Upload de documento"
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => openUploadModal(step)}
|
||||
aria-label="Upload de documento"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
Enviar
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
Enviar
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1158,29 +1145,27 @@
|
||||
</div>
|
||||
</div>
|
||||
{#if instance.status === 'active'}
|
||||
<ActionGuard recurso="fluxos_documentos" acao="excluir">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error shrink-0"
|
||||
onclick={() => handleDeleteDocument(doc._id)}
|
||||
aria-label="Excluir documento {doc.name}"
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error shrink-0"
|
||||
onclick={() => handleDeleteDocument(doc._id)}
|
||||
aria-label="Excluir documento {doc.name}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</ActionGuard>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
3
apps/web/src/routes/(dashboard)/pedidos/+page.server.ts
Normal file
3
apps/web/src/routes/(dashboard)/pedidos/+page.server.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
3
apps/web/src/routes/(dashboard)/perfil/+page.server.ts
Normal file
3
apps/web/src/routes/(dashboard)/perfil/+page.server.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export const load = async ({ parent }) => {
|
||||
const { currentUser } = await parent();
|
||||
console.log(currentUser);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
await parent();
|
||||
};
|
||||
22
apps/web/src/routes/(public)/+layout.svelte
Normal file
22
apps/web/src/routes/(public)/+layout.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-base-100 text-base-content selection:bg-primary selection:text-primary-content flex min-h-screen flex-col font-sans"
|
||||
>
|
||||
<Header />
|
||||
|
||||
<main class="w-full flex-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
<!-- Toast Notifications (Sonner) -->
|
||||
<Toaster position="top-right" richColors closeButton expand={true} />
|
||||
</div>
|
||||
134
apps/web/src/routes/(public)/home/+page.svelte
Normal file
134
apps/web/src/routes/(public)/home/+page.svelte
Normal file
@@ -0,0 +1,134 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, CheckCircle2, ShieldCheck, Zap, Users, BarChart3 } from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import logo from '$lib/assets/logo_governo_PE.png';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Home - SGSE</title>
|
||||
<meta name="description" content="Sistema de Gestão de Secretaria - Governo de Pernambuco" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<!-- Hero Section -->
|
||||
<section class="relative overflow-hidden bg-base-100 pt-16 pb-32 lg:pt-32 lg:pb-48">
|
||||
<div class="absolute top-0 left-0 w-full h-full overflow-hidden z-0">
|
||||
<div class="absolute -top-[30%] -right-[10%] w-[70%] h-[70%] rounded-full bg-primary/5 blur-[100px]"></div>
|
||||
<div class="absolute bottom-[10%] -left-[10%] w-[50%] h-[50%] rounded-full bg-secondary/5 blur-[100px]"></div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 relative z-10">
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary font-medium text-sm mb-8 animate-fade-in-up">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
</span>
|
||||
Sistema de Gestão de Secretaria
|
||||
</div>
|
||||
|
||||
<h1 class="text-5xl md:text-7xl font-extrabold tracking-tight mb-8 text-base-content leading-tight">
|
||||
Simplificando a <span class="bg-clip-text text-transparent bg-gradient-to-r from-primary to-secondary">Gestão Pública</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl md:text-2xl text-base-content/70 mb-12 max-w-2xl mx-auto leading-relaxed">
|
||||
Uma plataforma unificada para otimizar processos, conectar departamentos e garantir eficiência na administração pública.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<a href={resolve('/login')} class="btn btn-primary btn-lg gap-2 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all hover:-translate-y-1">
|
||||
Acessar Sistema
|
||||
<ArrowRight class="w-5 h-5" />
|
||||
</a>
|
||||
<a href="#recursos" class="btn btn-ghost btn-lg gap-2">
|
||||
Conheça os Recursos
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="recursos" class="py-24 bg-base-200/50">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-4">Recursos Principais</h2>
|
||||
<p class="text-lg text-base-content/70 max-w-2xl mx-auto">
|
||||
Ferramentas desenvolvidas especificamente para atender às necessidades da gestão secretaria.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{#each [
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'Agilidade nos Processos',
|
||||
description: 'Automatize tarefas repetitivas e reduza o tempo de tramitação de documentos.'
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: 'Segurança de Dados',
|
||||
description: 'Proteção avançada para garantir a integridade e confidencialidade das informações.'
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Gestão de Pessoas',
|
||||
description: 'Ferramentas integradas para acompanhamento e desenvolvimento dos servidores.'
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Relatórios Inteligentes',
|
||||
description: 'Dashboards interativos para tomada de decisão baseada em dados reais.'
|
||||
},
|
||||
{
|
||||
icon: CheckCircle2,
|
||||
title: 'Controle de Ativos',
|
||||
description: 'Rastreamento completo de bens e recursos da secretaria.'
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Colaboração em Tempo Real',
|
||||
description: 'Conecte equipes e facilite a comunicação interna entre departamentos.'
|
||||
}
|
||||
] as feature}
|
||||
<div class="card bg-base-100 shadow-sm hover:shadow-md transition-shadow duration-300 border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
|
||||
<feature.icon class="w-6 h-6" />
|
||||
</div>
|
||||
<h3 class="card-title text-xl mb-2">{feature.title}</h3>
|
||||
<p class="text-base-content/70">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Call to Action -->
|
||||
<section class="py-24 relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-primary/5"></div>
|
||||
<div class="container mx-auto px-4 relative z-10">
|
||||
<div class="bg-base-100 rounded-3xl p-8 md:p-16 text-center shadow-xl border border-base-200 max-w-5xl mx-auto">
|
||||
<h2 class="text-3xl md:text-5xl font-bold mb-6">Pronto para começar?</h2>
|
||||
<p class="text-xl text-base-content/70 mb-10 max-w-2xl mx-auto">
|
||||
Acesse o portal e tenha todo o controle da secretaria na palma da sua mão.
|
||||
</p>
|
||||
<a href={resolve('/login')} class="btn btn-primary btn-lg shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all hover:-translate-y-1">
|
||||
Fazer Login Agora
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Custom animations if needed */
|
||||
@keyframes fade-in-up {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.8s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
24
apps/web/src/routes/(public)/login/+page.server.ts
Normal file
24
apps/web/src/routes/(public)/login/+page.server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { FunctionReference } from 'convex/server';
|
||||
|
||||
export const load = async ({ locals, url }) => {
|
||||
try {
|
||||
const client = createConvexHttpClient({ token: locals.token });
|
||||
const currentUser = await client.query(api.auth.getCurrentUser as FunctionReference<'query'>);
|
||||
|
||||
if (currentUser) {
|
||||
const redirectTo = url.searchParams.get('redirect');
|
||||
if (redirectTo && redirectTo.startsWith('/')) {
|
||||
throw redirect(302, redirectTo);
|
||||
}
|
||||
throw redirect(302, '/');
|
||||
}
|
||||
} catch (error) {
|
||||
// Se houver falha transitória na API/auth, ainda assim permitir renderizar a página de login.
|
||||
console.error('Erro ao validar sessão na página de login:', error);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
275
apps/web/src/routes/(public)/login/+page.svelte
Normal file
275
apps/web/src/routes/(public)/login/+page.svelte
Normal file
@@ -0,0 +1,275 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import type { FunctionReference } from 'convex/server';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import logo from '$lib/assets/logo_governo_PE.png';
|
||||
import { authClient } from '$lib/auth';
|
||||
import { obterIPPublico } from '$lib/utils/deviceInfo';
|
||||
import AnimatedBackgroundElements from '$lib/components/AnimatedBackgroundElements.svelte';
|
||||
import DecorativeTopLine from '$lib/components/DecorativeTopLine.svelte';
|
||||
import ErrorMessage from '$lib/components/ErrorMessage.svelte';
|
||||
import GlassCard from '$lib/components/GlassCard.svelte';
|
||||
import LoginInput from '$lib/components/login/LoginInput.svelte';
|
||||
import ShineEffect from '$lib/components/ShineEffect.svelte';
|
||||
import { LogIn } from 'lucide-svelte';
|
||||
|
||||
interface GPSLocation {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
precisao?: number;
|
||||
endereco?: string;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
pais?: string;
|
||||
}
|
||||
|
||||
let matricula = $state('');
|
||||
let senha = $state('');
|
||||
let erroLogin = $state('');
|
||||
let carregandoLogin = $state(false);
|
||||
|
||||
const convexClient = useConvexClient();
|
||||
|
||||
const redirectAfterLogin = $derived.by(() => {
|
||||
const redirectTo = page.url.searchParams.get('redirect');
|
||||
return redirectTo && redirectTo.startsWith('/') ? redirectTo : '/';
|
||||
});
|
||||
|
||||
async function handleLogin(e: Event) {
|
||||
e.preventDefault();
|
||||
erroLogin = '';
|
||||
carregandoLogin = true;
|
||||
|
||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : undefined;
|
||||
|
||||
// Obter IP público com timeout curto (não bloquear login)
|
||||
const ipPublicoPromise = obterIPPublico().catch(() => undefined);
|
||||
const ipPublicoTimeout = new Promise<undefined>((resolve) =>
|
||||
setTimeout(() => resolve(undefined), 2000)
|
||||
);
|
||||
const ipPublico = await Promise.race([ipPublicoPromise, ipPublicoTimeout]);
|
||||
|
||||
// Função para coletar GPS em background (não bloqueia login)
|
||||
async function coletarGPS(): Promise<GPSLocation> {
|
||||
try {
|
||||
const { obterLocalizacaoRapida } = await import('$lib/utils/deviceInfo');
|
||||
const gpsPromise = obterLocalizacaoRapida();
|
||||
const gpsTimeout = new Promise<GPSLocation>((resolve) =>
|
||||
setTimeout(() => resolve({}), 3000)
|
||||
);
|
||||
return await Promise.race([gpsPromise, gpsTimeout]);
|
||||
} catch (err) {
|
||||
console.warn('Erro ao obter GPS (não bloqueia login):', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const gpsPromise = coletarGPS();
|
||||
const result = await authClient.signIn.email(
|
||||
{ email: matricula.trim(), password: senha },
|
||||
{
|
||||
onError: async (ctx) => {
|
||||
try {
|
||||
let localizacaoGPS: GPSLocation = {};
|
||||
try {
|
||||
localizacaoGPS = await Promise.race([
|
||||
gpsPromise,
|
||||
new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
|
||||
]);
|
||||
} catch {
|
||||
// ignorar
|
||||
}
|
||||
|
||||
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
|
||||
matriculaOuEmail: matricula.trim(),
|
||||
sucesso: false,
|
||||
motivoFalha: ctx.error?.message || 'Erro desconhecido',
|
||||
userAgent,
|
||||
ipAddress: ipPublico,
|
||||
latitudeGPS: localizacaoGPS.latitude,
|
||||
longitudeGPS: localizacaoGPS.longitude,
|
||||
precisaoGPS: localizacaoGPS.precisao,
|
||||
enderecoGPS: localizacaoGPS.endereco,
|
||||
cidadeGPS: localizacaoGPS.cidade,
|
||||
estadoGPS: localizacaoGPS.estado,
|
||||
paisGPS: localizacaoGPS.pais
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro ao registrar tentativa de login falha:', err);
|
||||
}
|
||||
|
||||
erroLogin = ctx.error?.message || 'Erro ao fazer login';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (result.data) {
|
||||
// Registrar tentativa de login bem-sucedida sem bloquear o redirect
|
||||
(async () => {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
let localizacaoGPS: GPSLocation = {};
|
||||
try {
|
||||
localizacaoGPS = await Promise.race([
|
||||
gpsPromise,
|
||||
new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
|
||||
]);
|
||||
} catch {
|
||||
// ignorar
|
||||
}
|
||||
|
||||
const usuario = (await convexClient.query(
|
||||
api.auth.getCurrentUser as unknown as FunctionReference<'query'>,
|
||||
{}
|
||||
)) as { _id?: Id<'usuarios'> } | null;
|
||||
|
||||
if (usuario && usuario._id) {
|
||||
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
|
||||
usuarioId: usuario._id,
|
||||
matriculaOuEmail: matricula.trim(),
|
||||
sucesso: true,
|
||||
userAgent,
|
||||
ipAddress: ipPublico,
|
||||
latitudeGPS: localizacaoGPS.latitude,
|
||||
longitudeGPS: localizacaoGPS.longitude,
|
||||
precisaoGPS: localizacaoGPS.precisao,
|
||||
enderecoGPS: localizacaoGPS.endereco,
|
||||
cidadeGPS: localizacaoGPS.cidade,
|
||||
estadoGPS: localizacaoGPS.estado,
|
||||
paisGPS: localizacaoGPS.pais
|
||||
});
|
||||
} else {
|
||||
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
|
||||
matriculaOuEmail: matricula.trim(),
|
||||
sucesso: true,
|
||||
userAgent,
|
||||
ipAddress: ipPublico,
|
||||
latitudeGPS: localizacaoGPS.latitude,
|
||||
longitudeGPS: localizacaoGPS.longitude,
|
||||
precisaoGPS: localizacaoGPS.precisao,
|
||||
enderecoGPS: localizacaoGPS.endereco,
|
||||
cidadeGPS: localizacaoGPS.cidade,
|
||||
estadoGPS: localizacaoGPS.estado,
|
||||
paisGPS: localizacaoGPS.pais
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao registrar tentativa de login:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
await goto(resolve(redirectAfterLogin as string), { replaceState: true });
|
||||
} else {
|
||||
erroLogin = result.error?.message || 'Erro ao fazer login';
|
||||
}
|
||||
|
||||
carregandoLogin = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main
|
||||
class="bg-base-100 relative flex min-h-screen w-full items-center justify-center overflow-hidden"
|
||||
>
|
||||
<AnimatedBackgroundElements />
|
||||
|
||||
<!-- Glass Card -->
|
||||
<div class="relative z-10 w-full max-w-md p-6">
|
||||
<GlassCard>
|
||||
<DecorativeTopLine />
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-10 text-center">
|
||||
<div
|
||||
class="bg-base-content/5 ring-base-content/10 mb-6 inline-flex items-center justify-center rounded-2xl p-4 shadow-inner ring-1"
|
||||
>
|
||||
<img src={logo} alt="Logo SGSE" class="h-12 w-auto object-contain" />
|
||||
</div>
|
||||
<h1 class="text-base-content mb-2 font-sans text-3xl font-bold tracking-tight">
|
||||
Bem-vindo de volta
|
||||
</h1>
|
||||
<p class="text-base-content/60 text-sm font-medium">
|
||||
Entre com suas credenciais para acessar o sistema
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<ErrorMessage message={erroLogin} />
|
||||
|
||||
<!-- Form -->
|
||||
<form class="space-y-6" onsubmit={handleLogin}>
|
||||
<LoginInput
|
||||
id="login-matricula"
|
||||
label="Matrícula ou E-mail"
|
||||
placeholder="Digite sua identificação"
|
||||
bind:value={matricula}
|
||||
required
|
||||
disabled={carregandoLogin}
|
||||
autocomplete="username"
|
||||
/>
|
||||
|
||||
<LoginInput
|
||||
id="login-password"
|
||||
label="Senha"
|
||||
type="password"
|
||||
placeholder="Digite sua senha"
|
||||
bind:value={senha}
|
||||
required
|
||||
disabled={carregandoLogin}
|
||||
autocomplete="current-password"
|
||||
>
|
||||
{#snippet right()}
|
||||
<a
|
||||
href={resolve('/esqueci-senha')}
|
||||
class="text-primary hover:text-primary-focus text-xs font-medium transition-colors"
|
||||
>
|
||||
Esqueceu a senha?
|
||||
</a>
|
||||
{/snippet}
|
||||
</LoginInput>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="group bg-primary hover:bg-primary-focus hover:shadow-primary/25 text-primary-content relative w-full overflow-hidden rounded-xl px-4 py-3.5 text-sm font-bold shadow-lg transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={carregandoLogin}
|
||||
>
|
||||
<div class="relative z-10 flex items-center justify-center gap-2">
|
||||
{#if carregandoLogin}
|
||||
<span
|
||||
class="border-primary-content/30 border-t-primary-content h-5 w-5 animate-spin rounded-full border-2"
|
||||
></span>
|
||||
<span>Autenticando...</span>
|
||||
{:else}
|
||||
<span>Entrar no Sistema</span>
|
||||
<LogIn class="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Shine Effect -->
|
||||
<ShineEffect />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-base-content/40 text-sm">
|
||||
Precisa de ajuda?
|
||||
<a
|
||||
href={resolve('/abrir-chamado')}
|
||||
class="text-base-content/70 hover:text-base-content font-medium decoration-1 transition-colors hover:underline"
|
||||
>
|
||||
Abrir um chamado
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<!-- Footer Info -->
|
||||
<div class="text-base-content/40 mt-8 text-center text-xs">
|
||||
<p>© {new Date().getFullYear()} Governo de Pernambuco. Todos os direitos reservados.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -3,6 +3,10 @@
|
||||
import { Shield, FileText, Mail, Phone, Calendar } from 'lucide-svelte';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { useAuth } from '@mmailaender/convex-better-auth-svelte/svelte';
|
||||
|
||||
const auth = useAuth();
|
||||
const isAuthenticated = $derived(auth.isAuthenticated);
|
||||
|
||||
const configLGPD = useQuery(api.lgpd.obterConfiguracaoLGPD, {});
|
||||
|
||||
@@ -66,7 +70,7 @@
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<FileText class="text-primary mt-1 h-5 w-5 flex-shrink-0" />
|
||||
<FileText class="text-primary mt-1 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<h3 class="font-semibold">Dados de Identificação</h3>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
@@ -76,7 +80,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<FileText class="text-primary mt-1 h-5 w-5 flex-shrink-0" />
|
||||
<FileText class="text-primary mt-1 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<h3 class="font-semibold">Dados de Contato</h3>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
@@ -85,7 +89,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<FileText class="text-primary mt-1 h-5 w-5 flex-shrink-0" />
|
||||
<FileText class="text-primary mt-1 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<h3 class="font-semibold">Dados Profissionais</h3>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
@@ -95,7 +99,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<FileText class="text-primary mt-1 h-5 w-5 flex-shrink-0" />
|
||||
<FileText class="text-primary mt-1 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<h3 class="font-semibold">Dados de Saúde</h3>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
@@ -105,7 +109,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<FileText class="text-primary mt-1 h-5 w-5 flex-shrink-0" />
|
||||
<FileText class="text-primary mt-1 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<h3 class="font-semibold">Dados de Acesso</h3>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
@@ -346,15 +350,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<a
|
||||
href={resolve('/privacidade/meus-dados')}
|
||||
class="btn btn-primary btn-lg w-full md:w-auto"
|
||||
>
|
||||
<FileText class="h-5 w-5" />
|
||||
Solicitar Meus Direitos
|
||||
</a>
|
||||
</div>
|
||||
{#if isAuthenticated}
|
||||
<div class="mt-6">
|
||||
<a
|
||||
href={resolve('/perfil/privacidade/meus-dados')}
|
||||
class="btn btn-primary btn-lg w-full md:w-auto"
|
||||
>
|
||||
<FileText class="h-5 w-5" />
|
||||
Solicitar Meus Direitos
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -420,15 +426,17 @@
|
||||
</section>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="mt-8 flex flex-col gap-4 sm:flex-row">
|
||||
<a href={resolve('/privacidade/meus-dados')} class="btn btn-primary btn-lg flex-1">
|
||||
<FileText class="h-5 w-5" />
|
||||
Solicitar Meus Direitos LGPD
|
||||
</a>
|
||||
<a href={resolve('/termo-consentimento')} class="btn btn-outline btn-lg flex-1">
|
||||
<Shield class="h-5 w-5" />
|
||||
Ver Termo de Consentimento
|
||||
</a>
|
||||
</div>
|
||||
{#if isAuthenticated}
|
||||
<div class="mt-8 flex flex-col gap-4 sm:flex-row">
|
||||
<a href={resolve('/perfil/privacidade/meus-dados')} class="btn btn-primary btn-lg flex-1">
|
||||
<FileText class="h-5 w-5" />
|
||||
Solicitar Meus Direitos LGPD
|
||||
</a>
|
||||
<a href={resolve('/termo-consentimento')} class="btn btn-outline btn-lg flex-1">
|
||||
<Shield class="h-5 w-5" />
|
||||
Ver Termo de Consentimento
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { createSvelteAuthClient } from '@mmailaender/convex-better-auth-svelte/svelte';
|
||||
import { authClient } from '$lib/auth';
|
||||
// Importar polyfill ANTES de qualquer outro código que possa usar Jitsi
|
||||
@@ -8,6 +8,7 @@
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { aplicarTema, aplicarTemaPadrao } from '$lib/utils/temas';
|
||||
import { themeChange } from 'theme-change';
|
||||
|
||||
const { children } = $props();
|
||||
|
||||
@@ -16,6 +17,36 @@
|
||||
// Buscar usuário atual para aplicar tema
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
function obterTemaPersistido(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const tema = window.localStorage.getItem('theme');
|
||||
return tema && tema.trim() ? tema : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function aplicarTemaDaisyUI(tema: string): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const htmlElement = document.documentElement;
|
||||
htmlElement.setAttribute('data-theme', tema);
|
||||
// Evita que `body[data-theme]` sobrescreva o tema do `<html>`
|
||||
if (document.body) document.body.removeAttribute('data-theme');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Habilita `data-set-theme` e `data-choose-theme` e persiste em localStorage ("theme")
|
||||
// Em Svelte, precisamos passar `false` para não depender de `DOMContentLoaded`
|
||||
themeChange(false);
|
||||
|
||||
// Garante que o tema persistido no device tenha prioridade
|
||||
const temaPersistido = obterTemaPersistido();
|
||||
if (temaPersistido) {
|
||||
aplicarTemaDaisyUI(temaPersistido);
|
||||
}
|
||||
});
|
||||
|
||||
// Aplicar tema quando o usuário for carregado
|
||||
$effect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
@@ -24,6 +55,13 @@
|
||||
|
||||
// Aguardar um pouco para garantir que o DOM está pronto
|
||||
const timeoutId = setTimeout(() => {
|
||||
const temaPersistido = obterTemaPersistido();
|
||||
if (temaPersistido) {
|
||||
// Prioridade do device (localStorage) mesmo para usuários logados
|
||||
aplicarTemaDaisyUI(temaPersistido);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentUser?.data?.temaPreferido) {
|
||||
// Usuário logado com tema preferido - aplicar tema salvo
|
||||
aplicarTema(currentUser.data.temaPreferido);
|
||||
@@ -41,6 +79,12 @@
|
||||
// Aplicar tema padrão imediatamente ao carregar (antes de verificar usuário)
|
||||
$effect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
const temaPersistido = obterTemaPersistido();
|
||||
if (temaPersistido) {
|
||||
aplicarTemaDaisyUI(temaPersistido);
|
||||
return;
|
||||
}
|
||||
|
||||
// Se não há tema aplicado ainda, aplicar o padrão imediatamente
|
||||
const htmlElement = document.documentElement;
|
||||
if (!htmlElement.getAttribute('data-theme')) {
|
||||
@@ -50,8 +94,4 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="flex">
|
||||
<Sidebar>{@render children()}</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
{@render children()}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
|
||||
let newTodoText = $state('');
|
||||
let isAdding = $state(false);
|
||||
let addError = $state<Error | null>(null);
|
||||
let togglingId = $state<Id<'todos'> | null>(null);
|
||||
let toggleError = $state<Error | null>(null);
|
||||
let deletingId = $state<Id<'todos'> | null>(null);
|
||||
let deleteError = $state<Error | null>(null);
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
const todosQuery = useQuery(api.todos.getAll, {});
|
||||
|
||||
async function handleAddTodo(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const text = newTodoText.trim();
|
||||
if (!text || isAdding) return;
|
||||
|
||||
isAdding = true;
|
||||
addError = null;
|
||||
try {
|
||||
await client.mutation(api.todos.create, { text });
|
||||
newTodoText = '';
|
||||
} catch (err) {
|
||||
console.error('Failed to add todo:', err);
|
||||
addError = err instanceof Error ? err : new Error(String(err));
|
||||
} finally {
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTodo(id: Id<'todos'>, completed: boolean) {
|
||||
if (togglingId === id || deletingId === id) return;
|
||||
|
||||
togglingId = id;
|
||||
toggleError = null;
|
||||
try {
|
||||
await client.mutation(api.todos.toggle, { id, completed: !completed });
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle todo:', err);
|
||||
toggleError = err instanceof Error ? err : new Error(String(err));
|
||||
} finally {
|
||||
if (togglingId === id) {
|
||||
togglingId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteTodo(id: Id<'todos'>) {
|
||||
if (togglingId === id || deletingId === id) return;
|
||||
|
||||
deletingId = id;
|
||||
deleteError = null;
|
||||
try {
|
||||
await client.mutation(api.todos.deleteTodo, { id });
|
||||
} catch (err) {
|
||||
console.error('Failed to delete todo:', err);
|
||||
deleteError = err instanceof Error ? err : new Error(String(err));
|
||||
} finally {
|
||||
if (deletingId === id) {
|
||||
deletingId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canAdd = $derived(!isAdding && newTodoText.trim().length > 0);
|
||||
const isLoadingTodos = $derived(todosQuery.isLoading);
|
||||
const todos = $derived(todosQuery.data ?? []);
|
||||
const hasTodos = $derived(todos.length > 0);
|
||||
</script>
|
||||
|
||||
<div class="p-4">
|
||||
<h1 class="mb-4 text-xl">Todos (Convex)</h1>
|
||||
|
||||
<form onsubmit={handleAddTodo} class="mb-4 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newTodoText}
|
||||
placeholder="New task..."
|
||||
disabled={isAdding}
|
||||
class="flex-grow p-1"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canAdd}
|
||||
class="rounded bg-blue-500 px-3 py-1 text-white disabled:opacity-50"
|
||||
>
|
||||
{#if isAdding}Adding...{:else}Add{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if isLoadingTodos}
|
||||
<p>Loading...</p>
|
||||
{:else if !hasTodos}
|
||||
<p>No todos yet.</p>
|
||||
{:else}
|
||||
<ul class="space-y-1">
|
||||
{#each todos as todo (todo._id)}
|
||||
{@const isTogglingThis = togglingId === todo._id}
|
||||
{@const isDeletingThis = deletingId === todo._id}
|
||||
{@const isDisabled = isTogglingThis || isDeletingThis}
|
||||
<li class="flex items-center justify-between p-2" class:opacity-50={isDisabled}>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`todo-${todo._id}`}
|
||||
checked={todo.completed}
|
||||
onchange={() => handleToggleTodo(todo._id, todo.completed)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<label for={`todo-${todo._id}`} class:line-through={todo.completed}>
|
||||
{todo.text}
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDeleteTodo(todo._id)}
|
||||
disabled={isDisabled}
|
||||
aria-label="Delete todo"
|
||||
class="px-1 text-red-500 disabled:opacity-50"
|
||||
>
|
||||
{#if isDeletingThis}Deleting...{:else}X{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if todosQuery.error}
|
||||
<p class="mt-4 text-red-500">
|
||||
Error loading: {todosQuery.error?.message ?? 'Unknown error'}
|
||||
</p>
|
||||
{/if}
|
||||
{#if addError}
|
||||
<p class="mt-4 text-red-500">
|
||||
Error adding: {addError.message ?? 'Unknown error'}
|
||||
</p>
|
||||
{/if}
|
||||
{#if toggleError}
|
||||
<p class="mt-4 text-red-500">
|
||||
Error updating: {toggleError.message ?? 'Unknown error'}
|
||||
</p>
|
||||
{/if}
|
||||
{#if deleteError}
|
||||
<p class="mt-4 text-red-500">
|
||||
Error deleting: {deleteError.message ?? 'Unknown error'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user