feat: bootstrap project

This commit is contained in:
Duarte
2026-05-31 20:22:50 +01:00
commit 66581ef584
65 changed files with 7915 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
user: {
id: string;
username: string;
role: string;
} | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+84
View File
@@ -0,0 +1,84 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { redirect, type Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const sessionId = event.cookies.get('session');
event.locals.user = null;
if (sessionId) {
try {
// Query session and join with user info
const result = db
.select({
session: schema.sessions,
user: schema.users
})
.from(schema.sessions)
.innerJoin(schema.users, eq(schema.sessions.userId, schema.users.id))
.where(eq(schema.sessions.id, sessionId))
.get();
if (result) {
const { session, user } = result;
if (session.expiresAt > Date.now()) {
event.locals.user = {
id: user.id,
username: user.username,
role: user.role
};
} else {
// Session expired, clean up
db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)).run();
event.cookies.delete('session', { path: '/' });
}
} else {
// Invalid session cookie
event.cookies.delete('session', { path: '/' });
}
} catch (err) {
console.error('Error in session auth hook:', err);
}
}
const path = event.url.pathname;
// Route protection
if (path.startsWith('/admin')) {
if (!event.locals.user) {
throw redirect(303, `/login?redirectTo=${encodeURIComponent(path)}`);
}
const role = event.locals.user.role;
if (path.startsWith('/admin/turnos')) {
if (role !== 'admin' && role !== 'shift_manager') {
if (role === 'volunteer') {
throw redirect(303, '/entregas');
}
throw redirect(303, '/login');
}
} else {
if (role !== 'admin') {
if (role === 'volunteer') {
throw redirect(303, '/entregas');
}
throw redirect(303, '/login');
}
}
}
if (path.startsWith('/entregas')) {
if (!event.locals.user) {
throw redirect(303, `/login?redirectTo=${encodeURIComponent(path)}`);
}
}
if (path === '/login' && event.locals.user) {
throw redirect(303, '/');
}
return resolve(event);
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+60
View File
@@ -0,0 +1,60 @@
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import * as schema from './schema';
import { env } from '$env/dynamic/private';
import bcrypt from 'bcrypt';
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
const client = new Database(env.DATABASE_URL);
export const db = drizzle(client, { schema });
// Seed admin user if the users table is empty (US00)
try {
const usersList = db.select().from(schema.users).all();
if (usersList.length === 0) {
console.log('Seeding default admin user (refoodpdn)...');
const passwordHash = bcrypt.hashSync('rpdn!2512', 10);
db.insert(schema.users).values({
username: 'refoodpdn',
passwordHash: passwordHash,
role: 'admin'
}).run();
console.log('Default admin user seeded successfully.');
}
} catch (err) {
console.error('Failed to seed default admin user:', err);
}
// Seed default shifts if the shifts table is empty (US06)
try {
const shiftsList = db.select().from(schema.shifts).all();
if (shiftsList.length === 0) {
console.log('Seeding default shifts (T1, T2, T3)...');
db.insert(schema.shifts).values([
{
code: 'T1',
startTime: '14:30',
endTime: '16:30',
days: '2,4' // Tuesday and Thursday
},
{
code: 'T2',
startTime: '16:30',
endTime: '18:30',
days: '2,4'
},
{
code: 'T3',
startTime: '18:30',
endTime: '20:30',
days: '2,4'
}
]).run();
console.log('Default shifts seeded successfully.');
}
} catch (err) {
console.error('Failed to seed default shifts:', err);
}
+52
View File
@@ -0,0 +1,52 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull(),
role: text('role').notNull(), // 'admin' | 'shift_manager' | 'volunteer'
createdAt: integer('created_at').notNull().$defaultFn(() => Date.now())
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: integer('expires_at').notNull()
});
export const beneficiaries = sqliteTable('beneficiaries', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
number: integer('number').notNull().unique(),
name: text('name').notNull(),
contact: text('contact').notNull(),
householdSize: integer('household_size').notNull().default(1),
observations: text('observations'),
status: text('status').notNull().default('ativo'), // 'ativo' | 'inativo'
createdAt: integer('created_at').notNull().$defaultFn(() => Date.now()),
updatedAt: integer('updated_at').notNull().$defaultFn(() => Date.now())
});
export const shifts = sqliteTable('shifts', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
code: text('code').notNull().unique(), // 'T1', 'T2', 'T3'
startTime: text('start_time').notNull(), // '14:30'
endTime: text('end_time').notNull(), // '16:30'
days: text('days').notNull(), // '2,4' (Tuesday, Thursday)
createdAt: integer('created_at').notNull().$defaultFn(() => Date.now()),
updatedAt: integer('updated_at').notNull().$defaultFn(() => Date.now())
});
export const deliveries = sqliteTable('deliveries', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
beneficiaryId: text('beneficiary_id')
.notNull()
.references(() => beneficiaries.id, { onDelete: 'cascade' }),
shiftId: text('shift_id')
.notNull()
.references(() => shifts.id, { onDelete: 'cascade' }),
date: text('date').notNull(), // 'YYYY-MM-DD'
createdAt: integer('created_at').notNull().$defaultFn(() => Date.now())
});
+7
View File
@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user
};
};
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import 'bootstrap/dist/css/bootstrap.min.css';
import favicon from '$lib/assets/favicon.svg';
import { browser } from '$app/environment';
let { data, children } = $props();
if (browser) {
// @ts-ignore
import('bootstrap/dist/js/bootstrap.bundle.min.js');
}
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</svelte:head>
<div class="app-wrapper d-flex flex-column min-vh-100">
{#if data?.user}
<nav class="navbar navbar-expand-lg navbar-dark shadow-sm py-2 px-3 border-bottom border-success border-2" style="background-color: #000000;">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center gap-2" href="/">
<img src="/logo.png" alt="Logo" class="d-inline-block align-text-top" style="height: 36px;" />
<span class="fw-bold tracking-wide">RefoodOne</span>
</a>
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0 gap-1">
{#if data.user.role === 'admin'}
<li class="nav-item dropdown">
<button type="button" class="nav-link dropdown-toggle px-3 rounded-2 bg-transparent border-0" id="navbarDropdown" data-bs-toggle="dropdown" aria-expanded="false">
Gestão
</button>
<ul class="dropdown-menu border-0 shadow mt-2" aria-labelledby="navbarDropdown">
<li>
<a class="dropdown-item py-2 px-3 rounded-2" href="/admin/beneficiarios">Beneficiários</a>
</li>
<li>
<a class="dropdown-item py-2 px-3 rounded-2" href="/admin/turnos">Turnos</a>
</li>
<li>
<a class="dropdown-item py-2 px-3 rounded-2" href="/admin/utilizadores">Utilizadores</a>
</li>
</ul>
</li>
{/if}
<li class="nav-item">
<a class="nav-link px-3 rounded-2" href="/entregas">Entregas</a>
</li>
</ul>
<div class="d-flex align-items-center gap-3">
<div class="text-light text-end d-none d-sm-block">
<div class="fw-semibold small">{data.user.username}</div>
<div class="text-white-50 text-uppercase" style="font-size: 0.65rem; letter-spacing: 0.05em;">{data.user.role}</div>
</div>
<form action="/logout" method="POST" class="m-0">
<button type="submit" class="btn btn-sm btn-outline-light px-3 py-1.5 rounded-2 d-flex align-items-center gap-2 fw-medium">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-box-arrow-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0z"/>
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708z"/>
</svg>
Sair
</button>
</form>
</div>
</div>
</div>
</nav>
{/if}
<main class="flex-grow-1">
{@render children()}
</main>
</div>
<style>
:global(:root) {
--refood-primary: #FCB515;
--refood-button: #1b3d22;
--refood-button-hover: #112515;
}
:global(body) {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--refood-primary);
}
:global(.btn-primary), :global(.btn-success) {
background-color: var(--refood-button) !important;
border-color: var(--refood-button) !important;
color: #ffffff !important;
font-weight: 600 !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
transition: all 0.15s ease-in-out !important;
}
:global(.btn-primary:hover), :global(.btn-success:hover) {
background-color: var(--refood-button-hover) !important;
border-color: var(--refood-button-hover) !important;
transform: translateY(-1px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.15), 0 4px 6px -2px rgba(0, 0, 0, 0.05) !important;
}
:global(.btn-primary:active), :global(.btn-success:active) {
transform: translateY(0);
}
.nav-link {
transition: all 0.2s ease-in-out;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
color: #ffffff !important;
}
:global(.dropdown-item:hover) {
background-color: var(--refood-primary) !important;
color: #000000 !important;
}
:global(.dropdown-item:active) {
background-color: var(--refood-primary) !important;
color: #000000 !important;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
return {
user: locals.user
};
};
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
let { data } = $props();
</script>
<svelte:head>
<title>Painel Principal - RefoodOne</title>
</svelte:head>
<div class="dashboard-container d-flex align-items-center justify-content-center py-5">
<div class="container text-center">
<div class="welcome-section mb-5 text-dark">
<h1 class="fw-bold display-5 tracking-tight">Olá, {data.user.username}!</h1>
<p class="fs-5 text-secondary opacity-75">Selecione uma opção para começar</p>
</div>
<div class="d-flex flex-wrap justify-content-center gap-4">
<!-- Entregas Card (Visible for everyone) -->
<a href="/entregas" class="dashboard-card d-flex flex-column align-items-center justify-content-center text-decoration-none shadow p-4 rounded-4 transition">
<div class="icon-wrapper d-flex align-items-center justify-content-center mb-3 text-white rounded-circle shadow-sm">
<!-- Basket Icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-basket3-fill" viewBox="0 0 16 16">
<path d="M5.757 1.071a.5.5 0 0 1 .172.686L3.383 6h9.234L10.07 1.757a.5.5 0 1 1 .858-.514L13.783 6H15.5a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5H.5a.5.5 0 0 1-.5-.5v-1A.5.5 0 0 1 .5 6h1.717L5.07 1.243a.5.5 0 0 1 .686-.172zM2.468 15.426.943 9h14.114l-1.525 6.426a.75.75 0 0 1-.729.574H3.197a.75.75 0 0 1-.73-.574z"/>
</svg>
</div>
<span class="card-label fw-bold text-dark fs-4">Entregas</span>
</a>
{#if data.user.role === 'admin'}
<!-- Beneficiários Card (Admin only) -->
<a href="/admin/beneficiarios" class="dashboard-card d-flex flex-column align-items-center justify-content-center text-decoration-none shadow p-4 rounded-4 transition">
<div class="icon-wrapper d-flex align-items-center justify-content-center mb-3 text-white rounded-circle shadow-sm">
<!-- People List Icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-people-fill" viewBox="0 0 16 16">
<path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6m-5.784 6A2.24 2.24 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.3 6.3 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1zM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5"/>
</svg>
</div>
<span class="card-label fw-bold text-dark fs-4">Beneficiários</span>
</a>
<!-- Utilizadores Card (Admin only) -->
<a href="/admin/utilizadores" class="dashboard-card d-flex flex-column align-items-center justify-content-center text-decoration-none shadow p-4 rounded-4 transition">
<div class="icon-wrapper d-flex align-items-center justify-content-center mb-3 text-white rounded-circle shadow-sm">
<!-- Key / Person Account Icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-person-badge-fill" viewBox="0 0 16 16">
<path d="M2 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2zm4.5 0a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zM8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6m5 2.755C12.146 12.825 10.623 12 8 12s-4.146.826-5 1.755V14a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1z"/>
</svg>
</div>
<span class="card-label fw-bold text-dark fs-4">Utilizadores</span>
</a>
{/if}
</div>
</div>
</div>
<style>
.dashboard-container {
min-height: calc(100vh - 72px);
background-color: var(--refood-primary);
}
.dashboard-card {
width: 220px;
height: 220px;
background: #ffffff;
border: 4px solid transparent;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.icon-wrapper {
width: 90px;
height: 90px;
background-color: var(--refood-button);
transition: all 0.25s ease;
}
.dashboard-card:hover {
transform: translateY(-8px);
border-color: var(--refood-button);
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.15) !important;
}
.dashboard-card:hover .icon-wrapper {
background-color: var(--refood-button-hover);
transform: scale(1.05);
}
.dashboard-card:active {
transform: translateY(-2px);
}
.tracking-tight {
letter-spacing: -0.025em;
}
</style>
@@ -0,0 +1,45 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { asc, eq, or, like } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const search = url.searchParams.get('search') || '';
const status = url.searchParams.get('status') || '';
try {
let query = db.select().from(schema.beneficiaries);
// We can fetch all and filter in memory since table size is guaranteed to be small (<20 tables, low concurrency).
// This keeps SQLite query logic extremely simple and readable.
let list = query.orderBy(asc(schema.beneficiaries.name)).all();
if (search) {
const searchLower = search.toLowerCase();
list = list.filter(
(b) =>
b.name.toLowerCase().includes(searchLower) ||
b.number.toString().includes(searchLower) ||
(b.contact && b.contact.includes(searchLower))
);
}
if (status && status !== 'todos') {
list = list.filter((b) => b.status === status);
}
return {
beneficiaries: list,
search,
status: status || 'todos'
};
} catch (err) {
console.error('Error loading beneficiaries:', err);
return {
beneficiaries: [],
search,
status: status || 'todos',
error: 'Erro ao carregar a lista de beneficiários.'
};
}
};
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import { page } from '$app/state';
let { data } = $props();
const successMessage = $derived(page.url.searchParams.get('success'));
</script>
<svelte:head>
<title>Beneficiários - RefoodOne</title>
</svelte:head>
<div class="container py-4">
{#if successMessage}
<div class="alert alert-success border-0 shadow-sm rounded-3 d-flex align-items-center gap-2 mb-4" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-check-circle-fill text-success" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
<div>
{successMessage}
</div>
</div>
{/if}
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-3 mb-4">
<div>
<h2 class="fw-bold text-dark mb-1">Beneficiários</h2>
<p class="text-muted mb-0">Gerir a listagem de beneficiários e agregados familiares</p>
</div>
<a href="/admin/beneficiarios/novo" class="btn btn-success btn-lg d-flex align-items-center gap-2 rounded-3 shadow-sm border-0" style="background-color: var(--refood-primary, #FCB515);">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2"/>
</svg>
Novo Beneficiário
</a>
</div>
<div class="card border-0 shadow-sm rounded-4 p-3 mb-4 bg-white">
<form method="GET" class="row g-3 align-items-end">
<div class="col-md-6 col-lg-7">
<label for="search" class="form-label fw-semibold text-secondary small">Pesquisar</label>
<input
type="text"
name="search"
id="search"
value={data.search}
placeholder="Pesquise por nome, número ou contacto..."
class="form-control rounded-3 border-2"
/>
</div>
<div class="col-md-3 col-lg-3">
<label for="status" class="form-label fw-semibold text-secondary small">Estado</label>
<select name="status" id="status" value={data.status} class="form-select rounded-3 border-2">
<option value="todos">Todos</option>
<option value="ativo">Ativo</option>
<option value="inativo">Inativo</option>
</select>
</div>
<div class="col-md-3 col-lg-2 d-grid">
<button type="submit" class="btn btn-primary rounded-3 fw-semibold border-0 py-2" style="background-color: #2c562e;">
Filtrar
</button>
</div>
</form>
</div>
<div class="card border-0 shadow-sm rounded-4 overflow-hidden bg-white">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-secondary fw-semibold">
<tr>
<th class="px-4 py-3" style="width: 120px;">Número</th>
<th class="py-3">Nome</th>
<th class="py-3">Contacto</th>
<th class="py-3 text-center" style="width: 120px;">Agregado</th>
<th class="py-3" style="width: 120px;">Estado</th>
<th class="px-4 py-3 text-end" style="width: 120px;">Ações</th>
</tr>
</thead>
<tbody>
{#if data.beneficiaries.length === 0}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="currentColor" class="bi bi-people mb-2 text-black-50" viewBox="0 0 16 16">
<path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1zm-7.978-1L7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002-.014.002zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4m3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0M6.936 9.28a6 6 0 0 0-1.23-.247A7 7 0 0 0 5 9c-4 0-5 3-5 4q0 1 2 1h4.216A2.24 2.24 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816M4.92 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4m3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0"/>
</svg>
<p class="mb-0">Nenhum beneficiário encontrado.</p>
</td>
</tr>
{:else}
{#each data.beneficiaries as beneficiary}
<tr>
<td class="px-4 fw-bold text-secondary">
#{beneficiary.number}
</td>
<td class="fw-semibold text-dark">
{beneficiary.name}
</td>
<td>
{beneficiary.contact}
</td>
<td class="text-center fw-medium">
{beneficiary.householdSize}
</td>
<td>
{#if beneficiary.status === 'ativo'}
<span class="badge bg-success-subtle text-success px-2.5 py-1.5 rounded-pill border border-success-subtle text-uppercase fw-semibold" style="font-size: 0.7rem;">Ativo</span>
{:else}
<span class="badge bg-danger-subtle text-danger px-2.5 py-1.5 rounded-pill border border-danger-subtle text-uppercase fw-semibold" style="font-size: 0.7rem;">Inativo</span>
{/if}
</td>
<td class="px-4 text-end">
<a href="/admin/beneficiarios/{beneficiary.id}" class="btn btn-sm btn-outline-secondary rounded-2 px-2.5 py-1 d-inline-flex align-items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"/>
</svg>
Editar
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,120 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq, and, ne } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const id = params.id;
try {
const beneficiary = db
.select()
.from(schema.beneficiaries)
.where(eq(schema.beneficiaries.id, id))
.get();
if (!beneficiary) {
throw error(404, 'Beneficiário não encontrado');
}
return {
beneficiary
};
} catch (err) {
if (err && typeof err === 'object' && 'status' in err && err.status === 404) {
throw err;
}
console.error('Error loading beneficiary details:', err);
throw error(500, 'Ocorreu um erro ao carregar os dados do beneficiário.');
}
};
export const actions: Actions = {
default: async ({ params, request }) => {
const id = params.id;
const data = await request.formData();
const numberStr = data.get('number')?.toString().trim();
const name = data.get('name')?.toString().trim();
const contact = data.get('contact')?.toString().trim();
const householdSizeStr = data.get('householdSize')?.toString().trim();
const observations = data.get('observations')?.toString().trim();
const status = data.get('status')?.toString().trim();
// Basic validation
if (!numberStr || !name || !contact || !householdSizeStr || !status) {
return fail(400, {
success: false,
error: 'Todos os campos obrigatórios devem ser preenchidos.'
});
}
const number = parseInt(numberStr, 10);
const householdSize = parseInt(householdSizeStr, 10);
if (isNaN(number) || number <= 0) {
return fail(400, {
success: false,
error: 'O número de beneficiário deve ser um número inteiro positivo.'
});
}
if (isNaN(householdSize) || householdSize < 1) {
return fail(400, {
success: false,
error: 'O agregado familiar deve ter pelo menos 1 pessoa.'
});
}
if (status !== 'ativo' && status !== 'inativo') {
return fail(400, {
success: false,
error: 'Estado inválido selecionado.'
});
}
try {
// Check if new number conflicts with another beneficiary (excluding self)
const existingConflict = db
.select()
.from(schema.beneficiaries)
.where(
and(
eq(schema.beneficiaries.number, number),
ne(schema.beneficiaries.id, id)
)
)
.get();
if (existingConflict) {
return fail(400, {
success: false,
error: `O número de beneficiário #${number} já está atribuído a outro registo.`
});
}
// Update details
db.update(schema.beneficiaries)
.set({
number,
name,
contact,
householdSize,
observations,
status,
updatedAt: Date.now()
})
.where(eq(schema.beneficiaries.id, id))
.run();
} catch (err) {
console.error('Error updating beneficiary details:', err);
return fail(500, {
success: false,
error: 'Erro ao guardar as alterações na base de dados.'
});
}
// Redirect on success
throw redirect(303, `/admin/beneficiarios?success=${encodeURIComponent('Alterações guardadas com sucesso.')}`);
}
};
@@ -0,0 +1,144 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form }: { data: any; form: any } = $props();
let isLoading = $state(false);
// Fallback to loaded data if form action result is empty
const beneficiary = $derived(data.beneficiary);
</script>
<svelte:head>
<title>Editar Beneficiário - RefoodOne</title>
</svelte:head>
<div class="container py-4">
<div class="mb-4">
<a href="/admin/beneficiarios" class="btn btn-link text-decoration-none p-0 d-inline-flex align-items-center gap-1 text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8"/>
</svg>
Voltar para a lista
</a>
<h2 class="fw-bold text-dark mt-2 mb-1">Editar Beneficiário</h2>
<p class="text-muted mb-0">Atualizar informações do beneficiário #{beneficiary.number}</p>
</div>
{#if form?.error}
<div class="alert alert-danger border-0 rounded-3 mb-4" role="alert">
{form.error}
</div>
{/if}
<div class="card border-0 shadow-sm rounded-4 p-4 bg-white" style="max-width: 800px;">
<form method="POST" use:enhance={() => {
isLoading = true;
return async ({ update }) => {
isLoading = false;
await update();
};
}}>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label for="number" class="form-label fw-semibold text-secondary small">Número do Beneficiário <span class="text-danger">*</span></label>
<input
type="number"
name="number"
id="number"
min="1"
step="1"
class="form-control rounded-3 border-2"
placeholder="Ex: 124"
value={form?.number ?? beneficiary.number}
required
disabled={isLoading}
/>
</div>
<div class="col-md-6">
<label for="status" class="form-label fw-semibold text-secondary small">Estado <span class="text-danger">*</span></label>
<select name="status" id="status" class="form-select rounded-3 border-2" disabled={isLoading}>
<option value="ativo" selected={(form?.status ?? beneficiary.status) === 'ativo'}>Ativo</option>
<option value="inativo" selected={(form?.status ?? beneficiary.status) === 'inativo'}>Inativo</option>
</select>
</div>
<div class="col-md-6">
<label for="name" class="form-label fw-semibold text-secondary small">Nome Completo <span class="text-danger">*</span></label>
<input
type="text"
name="name"
id="name"
class="form-control rounded-3 border-2"
placeholder="Nome do beneficiário"
value={form?.name ?? beneficiary.name}
required
disabled={isLoading}
/>
</div>
<div class="col-md-6">
<label for="contact" class="form-label fw-semibold text-secondary small">Contacto Telefónico <span class="text-danger">*</span></label>
<input
type="tel"
name="contact"
id="contact"
class="form-control rounded-3 border-2"
placeholder="Nº de telefone / telemóvel"
value={form?.contact ?? beneficiary.contact}
required
disabled={isLoading}
/>
</div>
<div class="col-md-6">
<label for="householdSize" class="form-label fw-semibold text-secondary small">Nº Pessoas do Agregado <span class="text-danger">*</span></label>
<input
type="number"
name="householdSize"
id="householdSize"
min="1"
step="1"
class="form-control rounded-3 border-2"
placeholder="Mínimo 1"
value={form?.householdSize ?? beneficiary.householdSize}
required
disabled={isLoading}
/>
</div>
<div class="col-12">
<label for="observations" class="form-label fw-semibold text-secondary small">Observações</label>
<textarea
name="observations"
id="observations"
rows="4"
class="form-control rounded-3 border-2"
placeholder="Notas adicionais, restrições alimentares, etc."
value={form?.observations ?? beneficiary.observations ?? ''}
disabled={isLoading}
></textarea>
</div>
</div>
<div class="d-flex justify-content-end gap-3 border-top pt-4">
<a href="/admin/beneficiarios" class="btn btn-outline-secondary rounded-3 px-4 py-2 fw-semibold">
Cancelar
</a>
<button
type="submit"
class="btn btn-success rounded-3 px-4 py-2 fw-bold text-white border-0 shadow-sm"
disabled={isLoading}
style="background-color: var(--refood-primary, #FCB515);"
>
{#if isLoading}
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
A guardar...
{:else}
Guardar
{/if}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,83 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const numberStr = data.get('number')?.toString().trim();
const name = data.get('name')?.toString().trim();
const contact = data.get('contact')?.toString().trim();
const householdSizeStr = data.get('householdSize')?.toString().trim();
const observations = data.get('observations')?.toString().trim();
// Basic validation
if (!numberStr || !name || !contact || !householdSizeStr) {
return fail(400, {
success: false,
error: 'Todos os campos obrigatórios devem ser preenchidos.'
});
}
const number = parseInt(numberStr, 10);
const householdSize = parseInt(householdSizeStr, 10);
if (isNaN(number) || number <= 0) {
return fail(400, {
success: false,
error: 'O número de beneficiário deve ser um número inteiro positivo.'
});
}
if (isNaN(householdSize) || householdSize < 1) {
return fail(400, {
success: false,
error: 'O agregado familiar deve ter pelo menos 1 pessoa.'
});
}
try {
// Check if number is unique
const existing = db
.select()
.from(schema.beneficiaries)
.where(eq(schema.beneficiaries.number, number))
.get();
if (existing) {
return fail(400, {
success: false,
number,
name,
contact,
householdSize,
observations,
error: `O número de beneficiário #${number} já se encontra registado.`
});
}
// Insert beneficiary
db.insert(schema.beneficiaries)
.values({
number,
name,
contact,
householdSize,
observations,
status: 'ativo'
})
.run();
} catch (err) {
console.error('Error creating beneficiary:', err);
return fail(500, {
success: false,
error: 'Ocorreu um erro ao guardar o beneficiário. Tente novamente.'
});
}
// Redirect on success
throw redirect(303, `/admin/beneficiarios?success=${encodeURIComponent('Beneficiário guardado com sucesso.')}`);
}
};
@@ -0,0 +1,133 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { form }: { form: any } = $props();
let isLoading = $state(false);
</script>
<svelte:head>
<title>Novo Beneficiário - RefoodOne</title>
</svelte:head>
<div class="container py-4">
<div class="mb-4">
<a href="/admin/beneficiarios" class="btn btn-link text-decoration-none p-0 d-inline-flex align-items-center gap-1 text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8"/>
</svg>
Voltar para a lista
</a>
<h2 class="fw-bold text-dark mt-2 mb-1">Novo Beneficiário</h2>
<p class="text-muted mb-0">Adicionar um novo beneficiário ao sistema</p>
</div>
{#if form?.error}
<div class="alert alert-danger border-0 rounded-3 mb-4" role="alert">
{form.error}
</div>
{/if}
<div class="card border-0 shadow-sm rounded-4 p-4 bg-white" style="max-width: 800px;">
<form method="POST" use:enhance={() => {
isLoading = true;
return async ({ update }) => {
isLoading = false;
await update();
};
}}>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label for="number" class="form-label fw-semibold text-secondary small">Número do Beneficiário <span class="text-danger">*</span></label>
<input
type="number"
name="number"
id="number"
min="1"
step="1"
class="form-control rounded-3 border-2"
placeholder="Ex: 124"
value={form?.number ?? ''}
required
disabled={isLoading}
/>
</div>
<div class="col-md-6">
<label for="householdSize" class="form-label fw-semibold text-secondary small">Nº Pessoas do Agregado <span class="text-danger">*</span></label>
<input
type="number"
name="householdSize"
id="householdSize"
min="1"
step="1"
class="form-control rounded-3 border-2"
placeholder="Mínimo 1"
value={form?.householdSize ?? 1}
required
disabled={isLoading}
/>
</div>
<div class="col-md-6">
<label for="name" class="form-label fw-semibold text-secondary small">Nome Completo <span class="text-danger">*</span></label>
<input
type="text"
name="name"
id="name"
class="form-control rounded-3 border-2"
placeholder="Nome do beneficiário"
value={form?.name ?? ''}
required
disabled={isLoading}
/>
</div>
<div class="col-md-6">
<label for="contact" class="form-label fw-semibold text-secondary small">Contacto Telefónico <span class="text-danger">*</span></label>
<input
type="tel"
name="contact"
id="contact"
class="form-control rounded-3 border-2"
placeholder="Nº de telefone / telemóvel"
value={form?.contact ?? ''}
required
disabled={isLoading}
/>
</div>
<div class="col-12">
<label for="observations" class="form-label fw-semibold text-secondary small">Observações</label>
<textarea
name="observations"
id="observations"
rows="4"
class="form-control rounded-3 border-2"
placeholder="Notas adicionais, restrições alimentares, etc."
value={form?.observations ?? ''}
disabled={isLoading}
></textarea>
</div>
</div>
<div class="d-flex justify-content-end gap-3 border-top pt-4">
<a href="/admin/beneficiarios" class="btn btn-outline-secondary rounded-3 px-4 py-2 fw-semibold">
Cancelar
</a>
<button
type="submit"
class="btn btn-success rounded-3 px-4 py-2 fw-bold text-white border-0 shadow-sm"
disabled={isLoading}
style="background-color: var(--refood-primary, #FCB515);"
>
{#if isLoading}
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
A guardar...
{:else}
Guardar
{/if}
</button>
</div>
</form>
</div>
</div>
+24
View File
@@ -0,0 +1,24 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { asc } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
try {
const list = db
.select()
.from(schema.shifts)
.orderBy(asc(schema.shifts.startTime))
.all();
return {
shifts: list
};
} catch (err) {
console.error('Error loading shifts:', err);
return {
shifts: [],
error: 'Erro ao carregar a lista de turnos.'
};
}
};
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import { page } from '$app/state';
let { data } = $props();
const successMessage = $derived(page.url.searchParams.get('success'));
const dayNames: Record<string, string> = {
'1': 'Segunda-feira',
'2': 'Terça-feira',
'3': 'Quarta-feira',
'4': 'Quinta-feira',
'5': 'Sexta-feira',
'6': 'Sábado',
'7': 'Domingo'
};
function formatDays(daysStr: string): string {
if (!daysStr) return 'Nenhum dia';
const days = daysStr.split(',').map(d => d.trim()).filter(Boolean);
// Specific friendly format for Terça and Quinta
if (days.length === 2 && days.includes('2') && days.includes('4')) {
return 'Terças e Quintas';
}
// General listing
return days.map(d => dayNames[d] || d).join(', ');
}
function calculateDuration(start: string, end: string): string {
try {
const [startH, startM] = start.split(':').map(Number);
const [endH, endM] = end.split(':').map(Number);
const totalMinutes = (endH * 60 + endM) - (startH * 60 + startM);
if (isNaN(totalMinutes) || totalMinutes <= 0) return 'N/A';
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (minutes === 0) return `${hours}h`;
return `${hours}h ${minutes}m`;
} catch {
return '2h';
}
}
</script>
<svelte:head>
<title>Gestão de Turnos - RefoodOne</title>
</svelte:head>
<div class="container py-4">
{#if successMessage}
<div class="alert alert-success border-0 shadow-sm rounded-3 d-flex align-items-center gap-2 mb-4" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-check-circle-fill text-success" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
<div>
{successMessage}
</div>
</div>
{/if}
{#if data.error}
<div class="alert alert-danger border-0 shadow-sm rounded-3 mb-4" role="alert">
{data.error}
</div>
{/if}
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-3 mb-4">
<div>
<h2 class="fw-bold text-dark mb-1">Gestão de Turnos</h2>
<p class="text-muted mb-0">Configurar os horários padrão e dias de funcionamento das entregas da Refood PdN</p>
</div>
</div>
<div class="card border-0 shadow-sm rounded-4 overflow-hidden bg-white">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-secondary fw-semibold">
<tr>
<th class="px-4 py-3" style="width: 150px;">Turno</th>
<th class="py-3">Horário</th>
<th class="py-3" style="width: 150px;">Duração</th>
<th class="py-3">Dias de Funcionamento</th>
<th class="px-4 py-3 text-end" style="width: 150px;">Ações</th>
</tr>
</thead>
<tbody>
{#if data.shifts.length === 0}
<tr>
<td colspan="5" class="text-center py-5 text-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="currentColor" class="bi bi-clock-history mb-2 text-black-50" viewBox="0 0 16 16">
<path d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022zm2.004.45a7 7 0 0 0-.985-.299l.219-.976c.383.086.76.2 1.126.342zm1.37.71a7 7 0 0 0-.439-.27l.493-.87a8 8 0 0 1 .979.654l-.615.789a7 7 0 0 0-.418-.302zm1.834 1.79a7 7 0 0 0-.653-.796l.724-.69c.27.285.52.59.747.91l-.818.576zm.744 1.352a7 7 0 0 0-.214-.468l.893-.45a8 8 0 0 1 .45 1.088l-.95.213a7 7 0 0 0-.179-.383zm.547 2.107a7 7 0 0 0-.1-1.025l.985-.17c.067.386.106.778.116 1.17l-1 .025zm-.314 2.119c.102-.29.177-.593.224-.903l.986.14a8 8 0 0 1-.41 1.623l-.756-.656a7 7 0 0 0-.044-.204zm-.897 1.838c.189-.236.353-.49.49-.76l.897.442a8 8 0 0 1-.84 1.258l-.547-.94zm-1.393 1.362c.264-.146.505-.32.722-.519l.666.744a8 8 0 0 1-1.25 1.026l-.138-1.251zm-1.84 1.037c.307-.066.602-.162.88-.286l.412.912a8 8 0 0 1-1.688.583l.396-1.209zm-2.012.449c.394-.017.78-.068 1.155-.15l.22.975a8 8 0 0 1-2.22.327zM8 2a6 6 0 1 1-6 6 6 6 0 0 1 6-6m8 6a8 8 0 1 1-16 0 8 8 0 0 1 16 0"/>
</svg>
<p class="mb-0">Nenhum turno configurado no sistema.</p>
</td>
</tr>
{:else}
{#each data.shifts as shift}
<tr>
<td class="px-4 fw-bold text-secondary">
{shift.code}
</td>
<td class="fw-semibold text-dark">
{shift.startTime} - {shift.endTime}
</td>
<td class="fw-medium text-secondary">
{calculateDuration(shift.startTime, shift.endTime)}
</td>
<td>
<span class="badge bg-light text-dark px-2.5 py-1.5 rounded-3 border fw-semibold">
{formatDays(shift.days)}
</span>
</td>
<td class="px-4 text-end">
<a href="/admin/turnos/{shift.id}" class="btn btn-sm btn-outline-secondary rounded-2 px-2.5 py-1 d-inline-flex align-items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"/>
</svg>
Editar
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,104 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const id = params.id;
try {
const shift = db
.select()
.from(schema.shifts)
.where(eq(schema.shifts.id, id))
.get();
if (!shift) {
throw error(404, 'Turno não encontrado');
}
return {
shift
};
} catch (err) {
if (err && typeof err === 'object' && 'status' in err && err.status === 404) {
throw err;
}
console.error('Error loading shift details:', err);
throw error(500, 'Ocorreu um erro ao carregar os dados do turno.');
}
};
export const actions: Actions = {
default: async ({ params, request }) => {
const id = params.id;
const data = await request.formData();
const startTime = data.get('startTime')?.toString().trim();
const endTime = data.get('endTime')?.toString().trim();
const daysArray = data.getAll('days').map((d) => d.toString().trim());
if (!startTime || !endTime) {
return fail(400, {
success: false,
error: 'As horas de início e fim são obrigatórias.'
});
}
if (daysArray.length === 0) {
return fail(400, {
success: false,
error: 'Deve selecionar pelo menos um dia da semana.'
});
}
// Validate time format and values (HH:MM)
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (!timeRegex.test(startTime) || !timeRegex.test(endTime)) {
return fail(400, {
success: false,
error: 'Formato de hora inválido. Use o formato HH:MM.'
});
}
const [startH, startM] = startTime.split(':').map(Number);
const [endH, endM] = endTime.split(':').map(Number);
const startMinutes = startH * 60 + startM;
const endMinutes = endH * 60 + endM;
if (endMinutes <= startMinutes) {
return fail(400, {
success: false,
error: 'A hora de fim deve ser posterior à hora de início.'
});
}
// Days should be sorted so it is stored consistently
const sortedDays = daysArray
.map(Number)
.filter((d) => d >= 1 && d <= 7)
.sort((a, b) => a - b)
.map(String)
.join(',');
try {
db.update(schema.shifts)
.set({
startTime,
endTime,
days: sortedDays,
updatedAt: Date.now()
})
.where(eq(schema.shifts.id, id))
.run();
} catch (err) {
console.error('Error updating shift:', err);
return fail(500, {
success: false,
error: 'Erro ao guardar as alterações na base de dados.'
});
}
throw redirect(303, `/admin/turnos?success=${encodeURIComponent('Horário do turno guardado com sucesso.')}`);
}
};
+136
View File
@@ -0,0 +1,136 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form }: { data: any; form: any } = $props();
let isLoading = $state(false);
const shift = $derived(data.shift);
// Parse initial days
const initialDays = $derived(shift.days ? shift.days.split(',').map((d: string) => d.trim()) : []);
const weekdays = [
{ value: '1', label: 'Segunda-feira' },
{ value: '2', label: 'Terça-feira' },
{ value: '3', label: 'Quarta-feira' },
{ value: '4', label: 'Quinta-feira' },
{ value: '5', label: 'Sexta-feira' },
{ value: '6', label: 'Sábado' },
{ value: '7', label: 'Domingo' }
];
</script>
<svelte:head>
<title>Editar Turno - RefoodOne</title>
</svelte:head>
<div class="container py-4">
<div class="mb-4">
<a href="/admin/turnos" class="btn btn-link text-decoration-none p-0 d-inline-flex align-items-center gap-1 text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8"/>
</svg>
Voltar para a lista
</a>
<h2 class="fw-bold text-dark mt-2 mb-1">Editar Turno</h2>
<p class="text-muted mb-0">Atualizar horário e dias de funcionamento do turno {shift.code}</p>
</div>
{#if form?.error}
<div class="alert alert-danger border-0 rounded-3 mb-4" role="alert">
{form.error}
</div>
{/if}
<div class="card border-0 shadow-sm rounded-4 p-4 bg-white" style="max-width: 700px;">
<form method="POST" use:enhance={() => {
isLoading = true;
return async ({ update }) => {
isLoading = false;
await update();
};
}}>
<div class="row g-3 mb-4">
<div class="col-md-12">
<label for="code" class="form-label fw-semibold text-secondary small">Identificador do Turno</label>
<input
type="text"
id="code"
class="form-control rounded-3 border-2 bg-light text-muted"
value={shift.code}
disabled
/>
</div>
<div class="col-md-6">
<label for="startTime" class="form-label fw-semibold text-secondary small">Hora de Início <span class="text-danger">*</span></label>
<input
type="time"
name="startTime"
id="startTime"
class="form-control rounded-3 border-2"
value={form?.startTime ?? shift.startTime}
required
disabled={isLoading}
/>
</div>
<div class="col-md-6">
<label for="endTime" class="form-label fw-semibold text-secondary small">Hora de Fim <span class="text-danger">*</span></label>
<input
type="time"
name="endTime"
id="endTime"
class="form-control rounded-3 border-2"
value={form?.endTime ?? shift.endTime}
required
disabled={isLoading}
/>
</div>
<div class="col-12">
<span class="form-label fw-semibold text-secondary d-block small mb-2">Dias de Funcionamento <span class="text-danger">*</span></span>
<div class="row g-2">
{#each weekdays as day}
<div class="col-sm-6 col-md-4">
<div class="form-check p-2 border rounded-3 bg-light-subtle d-flex align-items-center gap-2">
<input
type="checkbox"
name="days"
value={day.value}
id="day-{day.value}"
class="form-check-input ms-1"
checked={form?.days ? form.days.split(',').includes(day.value) : initialDays.includes(day.value)}
disabled={isLoading}
/>
<label class="form-check-label text-dark fw-medium small mb-0 w-100" for="day-{day.value}">
{day.label}
</label>
</div>
</div>
{/each}
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-3 border-top pt-4">
<a href="/admin/turnos" class="btn btn-outline-secondary rounded-3 px-4 py-2 fw-semibold">
Cancelar
</a>
<button
type="submit"
class="btn btn-success rounded-3 px-4 py-2 fw-bold text-white border-0 shadow-sm"
disabled={isLoading}
style="background-color: var(--refood-primary, #FCB515);"
>
{#if isLoading}
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
A guardar...
{:else}
Guardar
{/if}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,29 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { asc } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
try {
const list = db
.select({
id: schema.users.id,
username: schema.users.username,
role: schema.users.role,
createdAt: schema.users.createdAt
})
.from(schema.users)
.orderBy(asc(schema.users.username))
.all();
return {
users: list
};
} catch (err) {
console.error('Error loading users:', err);
return {
users: [],
error: 'Erro ao carregar a lista de utilizadores.'
};
}
};
@@ -0,0 +1,97 @@
<script lang="ts">
import { page } from '$app/state';
let { data } = $props();
const successMessage = $derived(page.url.searchParams.get('success'));
function getRoleLabel(role: string): string {
switch (role) {
case 'admin':
return 'Administrador';
case 'shift_manager':
return 'Gestor de Turno';
case 'volunteer':
return 'Voluntário';
default:
return role;
}
}
</script>
<svelte:head>
<title>Utilizadores - RefoodOne</title>
</svelte:head>
<div class="container py-4">
{#if successMessage}
<div class="alert alert-success border-0 shadow-sm rounded-3 d-flex align-items-center gap-2 mb-4" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-check-circle-fill text-success" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
<div>
{successMessage}
</div>
</div>
{/if}
{#if data.error}
<div class="alert alert-danger border-0 shadow-sm rounded-3 mb-4" role="alert">
{data.error}
</div>
{/if}
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-3 mb-4">
<div>
<h2 class="fw-bold text-dark mb-1">Utilizadores</h2>
<p class="text-muted mb-0">Gerir contas de utilizadores e permissões de acesso</p>
</div>
<a href="/admin/utilizadores/novo" class="btn btn-success btn-lg d-flex align-items-center gap-2 rounded-3 shadow-sm border-0" style="background-color: var(--refood-primary, #FCB515);">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2"/>
</svg>
Novo Utilizador
</a>
</div>
<div class="card border-0 shadow-sm rounded-4 overflow-hidden bg-white">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-secondary fw-semibold">
<tr>
<th class="px-4 py-3">Nome</th>
<th class="py-3">Role</th>
<th class="px-4 py-3 text-end" style="width: 120px;">Ações</th>
</tr>
</thead>
<tbody>
{#if data.users.length === 0}
<tr>
<td colspan="3" class="text-center py-5 text-muted">
<p class="mb-0">Nenhum utilizador encontrado.</p>
</td>
</tr>
{:else}
{#each data.users as u}
<tr>
<td class="px-4 fw-bold text-dark">
{u.username}
</td>
<td>
<span class="badge bg-dark-subtle text-dark border px-2.5 py-1.5 rounded-3 fw-semibold">
{getRoleLabel(u.role)}
</span>
</td>
<td class="px-4 text-end">
<a href="/admin/utilizadores/{u.id}" class="btn btn-sm btn-outline-secondary rounded-3 px-3 py-1.5 fw-semibold transition">
Editar
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,129 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq, and, ne } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import bcrypt from 'bcrypt';
export const load: PageServerLoad = async ({ params }) => {
const id = params.id;
try {
const user = db
.select({
id: schema.users.id,
username: schema.users.username,
role: schema.users.role,
createdAt: schema.users.createdAt
})
.from(schema.users)
.where(eq(schema.users.id, id))
.get();
if (!user) {
throw error(404, 'Utilizador não encontrado');
}
return {
targetUser: user
};
} catch (err) {
if (err && typeof err === 'object' && 'status' in err && err.status === 404) {
throw err;
}
console.error('Error loading user details:', err);
throw error(500, 'Ocorreu um erro ao carregar os dados do utilizador.');
}
};
export const actions: Actions = {
default: async ({ params, request }) => {
const id = params.id;
const data = await request.formData();
const username = data.get('username')?.toString().trim();
const role = data.get('role')?.toString().trim();
const password = data.get('password')?.toString();
const confirmPassword = data.get('confirmPassword')?.toString();
if (!username || !role) {
return fail(400, {
success: false,
error: 'Nome de utilizador e Perfil são campos obrigatórios.'
});
}
if (role !== 'admin' && role !== 'shift_manager' && role !== 'volunteer') {
return fail(400, {
success: false,
error: 'Perfil selecionado é inválido.'
});
}
let shouldUpdatePassword = false;
if (password || confirmPassword) {
shouldUpdatePassword = true;
if (password !== confirmPassword) {
return fail(400, {
success: false,
error: 'As palavras-passe introduzidas não coincidem.'
});
}
if (password!.length < 4) {
return fail(400, {
success: false,
error: 'A palavra-passe deve ter pelo menos 4 caracteres.'
});
}
}
try {
// Check if username conflicts with another user (excluding self)
const existingConflict = db
.select()
.from(schema.users)
.where(
and(
eq(schema.users.username, username),
ne(schema.users.id, id)
)
)
.get();
if (existingConflict) {
return fail(400, {
success: false,
error: 'O nome de utilizador já está a ser utilizado por outra conta.'
});
}
// Update values
if (shouldUpdatePassword) {
const passwordHash = bcrypt.hashSync(password!, 10);
db.update(schema.users)
.set({
username,
role,
passwordHash
})
.where(eq(schema.users.id, id))
.run();
} else {
db.update(schema.users)
.set({
username,
role
})
.where(eq(schema.users.id, id))
.run();
}
} catch (err) {
console.error('Error updating user:', err);
return fail(500, {
success: false,
error: 'Erro ao guardar as alterações na base de dados.'
});
}
throw redirect(303, `/admin/utilizadores?success=${encodeURIComponent('Alterações guardadas com sucesso.')}`);
}
};
@@ -0,0 +1,111 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form }: { data: any; form: any } = $props();
let isLoading = $state(false);
const user = $derived(data.targetUser);
</script>
<svelte:head>
<title>Editar Utilizador - RefoodOne</title>
</svelte:head>
<div class="container py-4">
<div class="mb-4">
<a href="/admin/utilizadores" class="btn btn-link text-decoration-none p-0 d-inline-flex align-items-center gap-1 text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8"/>
</svg>
Voltar para a lista
</a>
<h2 class="fw-bold text-dark mt-2 mb-1">Editar Utilizador</h2>
<p class="text-muted mb-0">Atualizar informações da conta de {user.username}</p>
</div>
{#if form?.error}
<div class="alert alert-danger border-0 rounded-3 mb-4" role="alert">
{form.error}
</div>
{/if}
<div class="card border-0 shadow-sm rounded-4 p-4 bg-white" style="max-width: 800px;">
<form method="POST" use:enhance={() => {
isLoading = true;
return async ({ update }) => {
isLoading = false;
await update();
};
}}>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label for="username" class="form-label fw-semibold text-secondary small">Nome de Utilizador <span class="text-danger">*</span></label>
<input
type="text"
name="username"
id="username"
class="form-control rounded-3 border-2"
placeholder="Ex: joao.silva"
value={form?.username ?? user.username}
required
disabled={isLoading}
/>
</div>
<div class="col-md-6">
<label for="role" class="form-label fw-semibold text-secondary small">Perfil (Role) <span class="text-danger">*</span></label>
<select name="role" id="role" class="form-select rounded-3 border-2" disabled={isLoading} required>
<option value="admin" selected={(form?.role ?? user.role) === 'admin'}>Administrador</option>
<option value="shift_manager" selected={(form?.role ?? user.role) === 'shift_manager'}>Gestor de Turno</option>
<option value="volunteer" selected={(form?.role ?? user.role) === 'volunteer'}>Voluntário</option>
</select>
</div>
<hr class="my-4 text-muted" />
<div class="col-12">
<h5 class="fw-bold text-dark mb-1">Alterar Palavra-passe</h5>
<p class="text-muted small mb-3">Deixe estes campos em branco se não pretender alterar a palavra-passe atual.</p>
</div>
<div class="col-md-6">
<label for="password" class="form-label fw-semibold text-secondary small">Nova Palavra-passe</label>
<input
type="password"
name="password"
id="password"
class="form-control rounded-3 border-2"
placeholder="Nova palavra-passe"
disabled={isLoading}
/>
</div>
<div class="col-md-6">
<label for="confirmPassword" class="form-label fw-semibold text-secondary small">Confirmar Nova Palavra-passe</label>
<input
type="password"
name="confirmPassword"
id="confirmPassword"
class="form-control rounded-3 border-2"
placeholder="Confirmar nova palavra-passe"
disabled={isLoading}
/>
</div>
</div>
<div class="d-flex justify-content-end gap-3">
<a href="/admin/utilizadores" class="btn btn-outline-secondary rounded-3 px-4 py-2 fw-semibold">
Cancelar
</a>
<button type="submit" class="btn btn-success rounded-3 px-4 py-2 fw-bold text-white border-0 shadow-sm" disabled={isLoading}>
{#if isLoading}
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
A guardar...
{:else}
Guardar
{/if}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,91 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import bcrypt from 'bcrypt';
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const username = data.get('username')?.toString().trim();
const role = data.get('role')?.toString().trim();
const password = data.get('password')?.toString();
const confirmPassword = data.get('confirmPassword')?.toString();
if (!username || !role || !password || !confirmPassword) {
return fail(400, {
success: false,
username,
role,
error: 'Todos os campos obrigatórios devem ser preenchidos.'
});
}
if (role !== 'admin' && role !== 'shift_manager' && role !== 'volunteer') {
return fail(400, {
success: false,
username,
role,
error: 'Perfil selecionado é inválido.'
});
}
if (password !== confirmPassword) {
return fail(400, {
success: false,
username,
role,
error: 'As palavras-passe introduzidas não coincidem.'
});
}
if (password.length < 4) {
return fail(400, {
success: false,
username,
role,
error: 'A palavra-passe deve ter pelo menos 4 caracteres.'
});
}
try {
// Check if username already exists
const existing = db
.select()
.from(schema.users)
.where(eq(schema.users.username, username))
.get();
if (existing) {
return fail(400, {
success: false,
username,
role,
error: 'O nome de utilizador já está a ser utilizado.'
});
}
// Insert user
const passwordHash = bcrypt.hashSync(password, 10);
db.insert(schema.users)
.values({
username,
role,
passwordHash,
createdAt: Date.now()
})
.run();
} catch (err) {
console.error('Error creating user:', err);
return fail(500, {
success: false,
username,
role,
error: 'Erro interno ao guardar o utilizador. Tente novamente.'
});
}
throw redirect(303, `/admin/utilizadores?success=${encodeURIComponent('Utilizador criado com sucesso.')}`);
}
};
@@ -0,0 +1,109 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { form }: { form: any } = $props();
let isLoading = $state(false);
</script>
<svelte:head>
<title>Novo Utilizador - RefoodOne</title>
</svelte:head>
<div class="container py-4">
<div class="mb-4">
<a href="/admin/utilizadores" class="btn btn-link text-decoration-none p-0 d-inline-flex align-items-center gap-1 text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8"/>
</svg>
Voltar para a lista
</a>
<h2 class="fw-bold text-dark mt-2 mb-1">Novo Utilizador</h2>
<p class="text-muted mb-0">Criar uma nova conta de acesso ao sistema</p>
</div>
{#if form?.error}
<div class="alert alert-danger border-0 rounded-3 mb-4" role="alert">
{form.error}
</div>
{/if}
<div class="card border-0 shadow-sm rounded-4 p-4 bg-white" style="max-width: 800px;">
<form method="POST" use:enhance={() => {
isLoading = true;
return async ({ update }) => {
isLoading = false;
await update();
};
}}>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label for="username" class="form-label fw-semibold text-secondary small">Nome de Utilizador <span class="text-danger">*</span></label>
<input
type="text"
name="username"
id="username"
class="form-control rounded-3 border-2"
placeholder="Ex: joao.silva"
value={form?.username ?? ''}
required
disabled={isLoading}
/>
</div>
<div class="col-md-6">
<label for="role" class="form-label fw-semibold text-secondary small">Perfil (Role) <span class="text-danger">*</span></label>
<select name="role" id="role" class="form-select rounded-3 border-2" disabled={isLoading} required>
<option value="" disabled selected={!form?.role}>Selecione um perfil...</option>
<option value="admin" selected={form?.role === 'admin'}>Administrador</option>
<option value="shift_manager" selected={form?.role === 'shift_manager'}>Gestor de Turno</option>
<option value="volunteer" selected={form?.role === 'volunteer'}>Voluntário</option>
</select>
</div>
<hr class="my-4 text-muted" />
<h5 class="fw-bold text-dark mb-3">Definir Palavra-passe</h5>
<div class="col-md-6">
<label for="password" class="form-label fw-semibold text-secondary small">Palavra-passe <span class="text-danger">*</span></label>
<input
type="password"
name="password"
id="password"
class="form-control rounded-3 border-2"
placeholder="Palavra-passe"
required
disabled={isLoading}
/>
</div>
<div class="col-md-6">
<label for="confirmPassword" class="form-label fw-semibold text-secondary small">Confirmar Palavra-passe <span class="text-danger">*</span></label>
<input
type="password"
name="confirmPassword"
id="confirmPassword"
class="form-control rounded-3 border-2"
placeholder="Confirmar palavra-passe"
required
disabled={isLoading}
/>
</div>
</div>
<div class="d-flex justify-content-end gap-3">
<a href="/admin/utilizadores" class="btn btn-outline-secondary rounded-3 px-4 py-2 fw-semibold">
Cancelar
</a>
<button type="submit" class="btn btn-success rounded-3 px-4 py-2 fw-bold text-white border-0 shadow-sm" disabled={isLoading}>
{#if isLoading}
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
A guardar...
{:else}
Guardar
{/if}
</button>
</div>
</form>
</div>
</div>
+193
View File
@@ -0,0 +1,193 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq, and, asc } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
const todayStr = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' }); // 'YYYY-MM-DD'
try {
// Fetch active beneficiaries sorted by their unique number
const activeBeneficiaries = db
.select()
.from(schema.beneficiaries)
.where(eq(schema.beneficiaries.status, 'ativo'))
.orderBy(asc(schema.beneficiaries.number))
.all();
// Fetch all shifts
const shiftsList = db
.select()
.from(schema.shifts)
.orderBy(asc(schema.shifts.startTime))
.all();
// Fetch today's deliveries with associated beneficiary and shift details
const todayDeliveries = db
.select({
id: schema.deliveries.id,
date: schema.deliveries.date,
createdAt: schema.deliveries.createdAt,
beneficiary: schema.beneficiaries,
shift: schema.shifts
})
.from(schema.deliveries)
.innerJoin(schema.beneficiaries, eq(schema.deliveries.beneficiaryId, schema.beneficiaries.id))
.innerJoin(schema.shifts, eq(schema.deliveries.shiftId, schema.shifts.id))
.where(eq(schema.deliveries.date, todayStr))
.all();
// Compile the list of beneficiary IDs who already received a basket today
const deliveredIds = todayDeliveries.map((d) => d.beneficiary.id);
return {
beneficiaries: activeBeneficiaries,
shifts: shiftsList,
todayDeliveries,
deliveredIds
};
} catch (err) {
console.error('Error loading deliveries page:', err);
return {
beneficiaries: [],
shifts: [],
todayDeliveries: [],
deliveredIds: [],
error: 'Erro ao carregar os dados de entregas.'
};
}
};
export const actions: Actions = {
registar: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Sessão expirada. Faça login novamente.' });
}
const data = await request.formData();
const beneficiaryId = data.get('beneficiaryId')?.toString();
if (!beneficiaryId) {
return fail(400, { error: 'O ID do beneficiário é obrigatório.' });
}
const todayStr = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' }); // 'YYYY-MM-DD'
try {
// 1. Verify beneficiary exists and is active
const beneficiary = db
.select()
.from(schema.beneficiaries)
.where(eq(schema.beneficiaries.id, beneficiaryId))
.get();
if (!beneficiary) {
return fail(404, { error: 'Beneficiário não encontrado.' });
}
if (beneficiary.status !== 'ativo') {
return fail(400, { error: 'O beneficiário não se encontra ativo.' });
}
// 2. Prevent duplicate deliveries on the same day
const existing = db
.select()
.from(schema.deliveries)
.where(
and(
eq(schema.deliveries.beneficiaryId, beneficiaryId),
eq(schema.deliveries.date, todayStr)
)
)
.get();
if (existing) {
return fail(400, { error: `O beneficiário #${beneficiary.number} já recebeu um cabaz hoje.` });
}
// 3. Resolve shift automatically based on current day and time
const shifts = db.select().from(schema.shifts).all();
if (shifts.length === 0) {
return fail(500, { error: 'Não existem turnos configurados no sistema.' });
}
const now = new Date();
const dayOfWeek = now.getDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
const mappedDay = dayOfWeek === 0 ? 7 : dayOfWeek; // Map Sunday to 7
const currentMinutes = now.getHours() * 60 + now.getMinutes();
let activeShiftId = '';
// Try to find exact match: day of week and current time in shift window
for (const s of shifts) {
const days = s.days.split(',').map(Number);
if (days.includes(mappedDay)) {
const [startH, startM] = s.startTime.split(':').map(Number);
const [endH, endM] = s.endTime.split(':').map(Number);
const startMin = startH * 60 + startM;
const endMin = endH * 60 + endM;
if (currentMinutes >= startMin && currentMinutes <= endMin) {
activeShiftId = s.id;
break;
}
}
}
// Fallback: If no shift matches the time, find first shift today
if (!activeShiftId) {
const shiftsToday = shifts.filter((s) => s.days.split(',').map(Number).includes(mappedDay));
if (shiftsToday.length > 0) {
activeShiftId = shiftsToday[0].id;
} else {
// Fallback to first configured shift
activeShiftId = shifts[0].id;
}
}
// 4. Insert delivery record
db.insert(schema.deliveries)
.values({
beneficiaryId,
shiftId: activeShiftId,
date: todayStr,
createdAt: Date.now()
})
.run();
return { success: true };
} catch (err) {
console.error('Error logging delivery:', err);
return fail(500, { error: 'Erro ao registar a entrega na base de dados.' });
}
},
apagar: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Sessão expirada. Faça login novamente.' });
}
const data = await request.formData();
const deliveryId = data.get('deliveryId')?.toString();
if (!deliveryId) {
return fail(400, { error: 'O ID do registo de entrega é obrigatório.' });
}
try {
// Delete the delivery record
db.delete(schema.deliveries)
.where(eq(schema.deliveries.id, deliveryId))
.run();
return { success: true };
} catch (err) {
console.error('Error deleting delivery:', err);
return fail(500, { error: 'Erro ao apagar o registo de entrega.' });
}
}
};
+333
View File
@@ -0,0 +1,333 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
let activeModalBeneficiary = $state<any>(null);
let activeDeleteDelivery = $state<any>(null);
let isLoading = $state(false);
// Derived list of delivered IDs so UI updates dynamically
const deliveredSet = $derived(new Set(data.deliveredIds || []));
function openConfirmation(beneficiary: any) {
if (deliveredSet.has(beneficiary.id)) return;
activeModalBeneficiary = beneficiary;
}
function closeConfirmation() {
activeModalBeneficiary = null;
}
function openDeleteConfirmation(delivery: any) {
activeDeleteDelivery = delivery;
}
// svelte-ignore non_reactive_update
function closeDeleteConfirmation() {
activeDeleteDelivery = null;
}
function formatTime(timestamp: number): string {
try {
return new Date(timestamp).toLocaleTimeString('pt-PT', {
timeZone: 'Europe/Lisbon',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return '';
}
}
</script>
<svelte:head>
<title>Registo de Entregas - RefoodOne</title>
</svelte:head>
<div class="container py-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-3 mb-4">
<div>
<h2 class="fw-bold text-dark mb-1">Registo de Entregas</h2>
<p class="text-muted mb-0">Selecione o número do beneficiário para registar a entrega do cabaz de hoje</p>
</div>
<div class="bg-dark text-white px-3 py-2 rounded-3 shadow-sm d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar-event" viewBox="0 0 16 16">
<path d="M11 6.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5z"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
</svg>
<span class="fw-semibold">{new Date().toLocaleDateString('pt-PT', { timeZone: 'Europe/Lisbon', weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</span>
</div>
</div>
{#if form?.error}
<div class="alert alert-danger border-0 rounded-3 mb-4 shadow-sm" role="alert">
{form.error}
</div>
{/if}
<!-- Beneficiaries Grid -->
<div class="card border-0 shadow-sm rounded-4 p-4 mb-4 bg-white">
<h5 class="fw-bold text-secondary mb-3">Selecione o Beneficiário</h5>
{#if data.beneficiaries.length === 0}
<div class="text-center py-5 text-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-person-slash mb-2 text-black-50" viewBox="0 0 16 16">
<path d="M13.879 10.414a2.501 2.501 0 0 0-3.465 3.465zm.707.707-3.465 3.465a2.501 2.501 0 0 0 3.465-3.465m-4.56-1.096a3.5 3.5 0 1 1 4.949 4.95 3.5 3.5 0 0 1-4.95-4.95M11 5a3 3 0 1 1-6 0 3 3 0 0 1 6 0M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4m.002 6a4.99 4.99 0 0 1 5.021-1.268 4 4 0 0 0-3.094-1.148H6a4 4 0 0 0-4 4v.5c0 .502.405.82 1 .82h5.216a5 5 0 0 1-.214-1.025L3.008 13z"/>
</svg>
<p class="mb-0">Não existem beneficiários ativos registados no sistema.</p>
</div>
{:else}
<div class="d-flex flex-wrap gap-3 justify-content-center justify-content-md-start">
{#each data.beneficiaries as beneficiary}
{@const isDelivered = deliveredSet.has(beneficiary.id)}
<button
type="button"
class="btn-grid-beneficiary d-flex flex-column align-items-center justify-content-center rounded-4 border-2 transition shadow-sm {isDelivered ? 'delivered' : 'active'}"
onclick={() => openConfirmation(beneficiary)}
disabled={isDelivered}
aria-label="Beneficiário número {beneficiary.number} - {beneficiary.name}"
>
<span class="beneficiary-number fw-bold mb-0">{beneficiary.number}</span>
<span class="beneficiary-first-name text-truncate w-100 px-1 {isDelivered ? 'text-success' : 'text-secondary'}" style="font-size: 0.7rem; font-weight: 500; text-align: center;">
{beneficiary.name.split(' ')[0]}
</span>
{#if isDelivered}
<span class="delivery-status-icon mt-0.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-check-circle-fill text-success" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Today's Recent Deliveries -->
<div class="card border-0 shadow-sm rounded-4 overflow-hidden bg-white">
<div class="card-header border-0 bg-light py-3 px-4">
<h5 class="fw-bold text-dark mb-0">Entregas de Hoje</h5>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-secondary fw-semibold">
<tr>
<th class="px-4 py-3" style="width: 120px;">Número</th>
<th class="py-3">Nome do Beneficiário</th>
<th class="py-3">Turno</th>
<th class="py-3" style="width: 180px;">Hora do Registo</th>
<th class="py-3 px-4 text-end" style="width: 100px;">Ações</th>
</tr>
</thead>
<tbody>
{#if data.todayDeliveries.length === 0}
<tr>
<td colspan="5" class="text-center py-4 text-muted">
Nenhum cabaz entregue hoje.
</td>
</tr>
{:else}
{#each data.todayDeliveries as delivery}
<tr>
<td class="px-4 fw-bold text-secondary">
#{delivery.beneficiary.number}
</td>
<td class="fw-semibold text-dark">
{delivery.beneficiary.name}
</td>
<td>
<span class="badge bg-dark-subtle text-dark border px-2.5 py-1.5 rounded-3 fw-semibold">
{delivery.shift.code} ({delivery.shift.startTime} - {delivery.shift.endTime})
</span>
</td>
<td class="text-secondary fw-medium">
{formatTime(delivery.createdAt)}
</td>
<td class="px-4 text-end">
<button
type="button"
class="btn btn-sm btn-link text-danger p-0 border-0 align-middle transition"
title="Apagar Registo"
onclick={() => openDeleteConfirmation(delivery)}
disabled={isLoading}
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-trash3" viewBox="0 0 16 16">
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5"/>
</svg>
</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
<!-- Svelte Confirmation Modal Backdrop & Popup -->
{#if activeModalBeneficiary}
<div class="modal-backdrop fade show"></div>
<div class="modal d-block fade show" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content border-0 shadow-lg rounded-4 p-3">
<div class="modal-header border-0 pb-1">
<h5 class="modal-title fw-bold fs-4 text-dark">Confirmar Entrega</h5>
<button type="button" class="btn-close" onclick={closeConfirmation} aria-label="Fechar"></button>
</div>
<div class="modal-body py-3">
<p class="text-muted mb-4">Confirme que está a efetuar a entrega do cabaz a este beneficiário:</p>
<div class="card bg-light border-0 rounded-3 p-3 mb-3">
<div class="row g-2">
<div class="col-4 text-secondary fw-semibold small">Beneficiário:</div>
<div class="col-8 fw-bold text-dark">#{activeModalBeneficiary.number} - {activeModalBeneficiary.name}</div>
<div class="col-4 text-secondary fw-semibold small">Agregado:</div>
<div class="col-8 fw-medium text-dark">{activeModalBeneficiary.householdSize} pessoa(s)</div>
<div class="col-4 text-secondary fw-semibold small">Data:</div>
<div class="col-8 fw-medium text-dark">{new Date().toLocaleDateString('pt-PT', { timeZone: 'Europe/Lisbon' })} (Hoje)</div>
</div>
</div>
</div>
<div class="modal-footer border-0 pt-1 d-flex gap-3">
<button type="button" class="btn btn-outline-secondary rounded-3 px-4 py-2 fw-semibold flex-grow-1" onclick={closeConfirmation} disabled={isLoading}>
Cancelar
</button>
<form
method="POST"
action="?/registar"
use:enhance={() => {
isLoading = true;
return async ({ update }) => {
isLoading = false;
activeModalBeneficiary = null;
await update();
};
}}
class="flex-grow-1 m-0"
>
<input type="hidden" name="beneficiaryId" value={activeModalBeneficiary.id} />
<button
type="submit"
class="btn btn-success rounded-3 px-4 py-2 fw-bold text-white border-0 shadow-sm w-100"
disabled={isLoading}
style="background-color: var(--refood-button, #1b3d22);"
>
{#if isLoading}
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
A confirmar...
{:else}
Confirmar
{/if}
</button>
</form>
</div>
</div>
</div>
</div>
{/if}
{#if activeDeleteDelivery}
<div class="modal-backdrop fade show"></div>
<div class="modal d-block fade show" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content border-0 shadow-lg rounded-4 p-3">
<div class="modal-header border-0 pb-1">
<h5 class="modal-title fw-bold fs-4 text-dark">Confirmar Eliminação</h5>
<button type="button" class="btn-close" onclick={closeDeleteConfirmation} aria-label="Fechar"></button>
</div>
<div class="modal-body py-3">
<p class="text-muted mb-4">Tem a certeza de que deseja eliminar o registo de entrega deste beneficiário?</p>
<div class="card bg-light border-0 rounded-3 p-3 mb-3">
<div class="row g-2">
<div class="col-4 text-secondary fw-semibold small">Beneficiário:</div>
<div class="col-8 fw-bold text-dark">#{activeDeleteDelivery.beneficiary.number} - {activeDeleteDelivery.beneficiary.name}</div>
<div class="col-4 text-secondary fw-semibold small">Turno:</div>
<div class="col-8 fw-medium text-dark">{activeDeleteDelivery.shift.code} ({activeDeleteDelivery.shift.startTime} - {activeDeleteDelivery.shift.endTime})</div>
<div class="col-4 text-secondary fw-semibold small">Hora:</div>
<div class="col-8 fw-medium text-dark">{formatTime(activeDeleteDelivery.createdAt)}</div>
</div>
</div>
</div>
<div class="modal-footer border-0 pt-1 d-flex gap-3">
<button type="button" class="btn btn-outline-secondary rounded-3 px-4 py-2 fw-semibold flex-grow-1" onclick={closeDeleteConfirmation} disabled={isLoading}>
Cancelar
</button>
<form
method="POST"
action="?/apagar"
use:enhance={() => {
isLoading = true;
return async ({ update }) => {
isLoading = false;
activeDeleteDelivery = null;
await update();
};
}}
class="flex-grow-1 m-0"
>
<input type="hidden" name="deliveryId" value={activeDeleteDelivery.id} />
<button
type="submit"
class="btn btn-danger rounded-3 px-4 py-2 fw-bold text-white border-0 shadow-sm w-100"
disabled={isLoading}
>
{#if isLoading}
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
A eliminar...
{:else}
Eliminar
{/if}
</button>
</form>
</div>
</div>
</div>
</div>
{/if}
<style>
.btn-grid-beneficiary {
width: 96px;
height: 96px;
background: #ffffff;
border-color: #dee2e6;
color: #495057;
outline: none;
}
.btn-grid-beneficiary.active {
cursor: pointer;
}
.btn-grid-beneficiary.active:hover {
border-color: var(--refood-button, #1b3d22);
background-color: rgba(27, 61, 34, 0.05);
color: var(--refood-button, #1b3d22);
transform: translateY(-2px);
}
.btn-grid-beneficiary.active:active {
transform: translateY(0);
}
.btn-grid-beneficiary.delivered {
background-color: #d1e7dd;
border-color: #a3cfbb;
color: #0f5132;
cursor: not-allowed;
}
.beneficiary-number {
font-size: 1.5rem;
line-height: 1;
}
.transition {
transition: all 0.15s ease-in-out;
}
</style>
+92
View File
@@ -0,0 +1,92 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import bcrypt from 'bcrypt';
export const load: PageServerLoad = async ({ locals }) => {
// If already logged in, redirect them
if (locals.user) {
throw redirect(303, '/');
}
return {};
};
export const actions: Actions = {
default: async ({ request, cookies, url }) => {
const data = await request.formData();
const username = data.get('username')?.toString().trim();
const password = data.get('password')?.toString();
if (!username || !password) {
return fail(400, {
success: false,
error: 'Utilizador e palavra-passe são obrigatórios.'
});
}
try {
// Find user
const user = db
.select()
.from(schema.users)
.where(eq(schema.users.username, username))
.get();
if (!user) {
return fail(400, {
success: false,
error: 'Utilizador ou palavra-passe incorretos.'
});
}
// Verify password hash
const passwordValid = bcrypt.compareSync(password, user.passwordHash);
if (!passwordValid) {
return fail(400, {
success: false,
error: 'Utilizador ou palavra-passe incorretos.'
});
}
// Create session (expires in 7 days)
const sessionId = crypto.randomUUID();
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000;
db.insert(schema.sessions)
.values({
id: sessionId,
userId: user.id,
expiresAt
})
.run();
// Set session cookie
cookies.set('session', sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: true,
maxAge: 60 * 60 * 24 * 7 // 7 days in seconds
});
// Redirect
const redirectTo = url.searchParams.get('redirectTo') || '';
if (redirectTo && redirectTo.startsWith('/')) {
throw redirect(303, redirectTo);
}
throw redirect(303, '/');
} catch (err) {
if (err && typeof err === 'object' && 'status' in err && err.status === 303) {
throw err; // Re-throw SvelteKit redirect
}
console.error('Error during login action:', err);
return fail(500, {
success: false,
error: 'Ocorreu um erro no servidor. Tente novamente mais tarde.'
});
}
}
};
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
let showPassword = $state(false);
let isLoading = $state(false);
function togglePassword() {
showPassword = !showPassword;
}
</script>
<svelte:head>
<title>Login - RefoodOne</title>
</svelte:head>
<div class="login-container d-flex align-items-center justify-content-center min-vh-100 bg-light">
<div class="card shadow-lg border-0 p-4 rounded-4" style="max-width: 400px; width: 100%;">
<div class="text-center mb-4">
<img src="/logo.png" alt="Refood Logo" class="img-fluid mb-2" style="max-height: 80px;" />
<h2 class="fw-bold text-dark mb-1">RefoodOne</h2>
<p class="text-muted small">Entre na sua conta para continuar</p>
</div>
{#if form?.error}
<div class="alert alert-danger border-0 rounded-3 text-center mb-3 py-2" role="alert">
{form.error}
</div>
{/if}
<form method="POST" use:enhance={() => {
isLoading = true;
return async ({ update }) => {
isLoading = false;
await update();
};
}}>
<div class="mb-3">
<label for="username" class="form-label fw-semibold text-secondary">Utilizador / E-mail</label>
<input
type="text"
name="username"
id="username"
class="form-control form-control-lg rounded-3 border-2"
placeholder="Ex: refoodpdn"
required
disabled={isLoading}
/>
</div>
<div class="mb-4">
<label for="password" class="form-label fw-semibold text-secondary">Palavra-passe</label>
<div class="input-group">
<input
type={showPassword ? 'text' : 'password'}
name="password"
id="password"
class="form-control form-control-lg rounded-start-3 border-2 border-end-0"
placeholder="••••••••"
required
disabled={isLoading}
/>
<button
type="button"
class="btn btn-outline-secondary rounded-end-3 border-2 border-start-0 bg-white"
onclick={togglePassword}
disabled={isLoading}
aria-label={showPassword ? 'Ocultar palavra-passe' : 'Mostrar palavra-passe'}
>
{#if showPassword}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-eye-slash-fill" viewBox="0 0 16 16">
<path d="m10.79 12.912-1.614-1.615a3.5 3.5 0 0 1-4.474-4.474l-2.06-2.06C.938 6.278 0 8 0 8s3 5.5 8 5.5a7 7 0 0 0 2.79-.588M5.21 3.088A7 7 0 0 1 8 2.5c5 0 8 5.5 8 5.5s-.79 1.45-2.235 3.032l-1.16-1.16a3.5 3.5 0 0 0-4.474-4.474z"/>
<path d="M5.525 7.646a2.5 2.5 0 0 0 2.829 2.829zm4.95.708-2.829-2.83a2.5 2.5 0 0 1 2.829 2.83zm-5.177 3.509 9.186-9.186 1.06 1.06-9.186 9.186z"/>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-eye-fill" viewBox="0 0 16 16">
<path d="M10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0"/>
<path d="M0 8s3-5.5 8-5.5S16 8 16 8s-3 5.5-8 5.5S0 8 0 8m8 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7"/>
</svg>
{/if}
</button>
</div>
</div>
<button
type="submit"
class="btn btn-primary btn-lg w-100 rounded-3 fw-bold text-white shadow-sm"
disabled={isLoading}
style="background-color: var(--refood-primary, #FCB515); border-color: var(--refood-primary, #FCB515);"
>
{#if isLoading}
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
A entrar...
{:else}
Entrar
{/if}
</button>
</form>
</div>
</div>
<style>
:global(:root) {
--refood-primary: #FCB515;
}
.form-control:focus {
border-color: var(--refood-primary);
box-shadow: 0 0 0 0.25rem rgba(252, 181, 21, 0.25);
}
</style>
+25
View File
@@ -0,0 +1,25 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ cookies }) => {
const sessionId = cookies.get('session');
if (sessionId) {
try {
// Delete session from DB
db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)).run();
} catch (err) {
console.error('Error deleting session during logout:', err);
}
// Clear cookie
cookies.delete('session', { path: '/' });
}
throw redirect(303, '/login');
}
};