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
+123
View File
@@ -0,0 +1,123 @@
# .clinrules
## Project Overview
This project is a personal web application developed using a unified, full-stack architecture:
* SvelteKit (latest stable, Node adapter)
* TypeScript
* Bootstrap 5
* SQLite (local file-based database)
* Drizzle ORM (lightweight, type-safe SQL query builder)
* Caddy or Nginx (production reverse proxy with SSL)
Primary goals:
1. Simplicity
2. Minimal operational overhead (suited for a small VPS with 2 cores, 4GB RAM)
3. High readability and maintainability
4. Fast development cycle
5. AI-generated code consistency
The application is targeted specifically for desktop browsers and tablet devices, and is expected to remain relatively small (<20 database tables) with low concurrency (<5 users).
---
## General Principles
* Prefer the simplest solution that satisfies the requirement.
* Avoid over-engineering and premature optimization.
* Avoid introducing unnecessary abstractions (e.g., repository patterns).
* Favor explicit code over clever/implicit code.
* Prioritize readability over conciseness.
---
## Architecture (SvelteKit Full-Stack)
We use SvelteKit's unified server/client model. There is no separate backend service.
```text
src/
├── routes/ # SvelteKit pages and server endpoints (+page.svelte, +page.server.ts, +server.ts)
├── lib/
│ ├── components/ # Reusable Svelte UI components
│ ├── db/ # Database connection, schemas, and migrations
│ │ ├── schema.ts # Drizzle schema definitions
│ │ └── index.ts # SQLite client initialization
│ ├── services/ # Server-side business logic
│ ├── types/ # Shared TypeScript definitions
│ └── utils/ # Helper functions
```
Rules:
* Use TypeScript everywhere.
* Keep components focused and small.
* Implement business logic in `lib/services/` or directly inside `+page.server.ts` actions.
* Never query the database directly from frontend code; database queries must occur exclusively in server files (`*.server.ts`).
---
## Database (SQLite + Drizzle)
Use:
* SQLite (local file: e.g., `data/sqlite.db`)
* Drizzle ORM for schema definitions and migrations
Rules:
* Define schemas explicitly in `src/lib/db/schema.ts`.
* Use Drizzle Kit for generating and running schema migrations.
* Use UUIDs or auto-incrementing integers for primary keys consistently.
* Define appropriate foreign keys and cascading delete rules.
---
## Authentication
Use:
* Secure cookie-based sessions (stored in the SQLite database).
* Password hashing using `argon2` or `bcrypt`.
* Manage sessions via SvelteKit server hooks (`src/hooks.server.ts`).
* Avoid external OAuth/identity providers unless explicitly requested.
---
## Bootstrap Rules
Use Bootstrap 5 as the primary UI framework.
Rules:
* Prefer standard Bootstrap components before creating custom CSS.
* Avoid unnecessary custom CSS; keep styling simple.
* Favor responsiveness using Bootstrap's responsive grid system and utility classes, with a primary design focus on desktop and tablet screens.
---
## Error Handling
* Handle errors explicitly on the server side.
* Return user-friendly error messages using SvelteKit's `error` helpers.
* Log technical details server-side. Never expose raw stack traces to the user interface.
---
## Deployment & Ops
* SvelteKit must be built using the Node adapter (`@sveltejs/adapter-node`) for server-side execution.
* The application runs locally or in production as a Node.js process listening on a port (e.g., `3000`).
* Nginx or Caddy handles SSL termination and reverse proxies requests to port `3000`.
---
## AI Agent Behavior
Before coding:
1. Understand the requirement.
2. Review existing files and follow established patterns.
3. Minimize changes.
Prohibitions (do NOT use unless explicitly requested):
* Clean Architecture / CQRS
* Event Sourcing
* Generic Repository Patterns
* Enterprise/Factory-heavy designs
+2
View File
@@ -0,0 +1,2 @@
# Drizzle
DATABASE_URL=local.db
+25
View File
@@ -0,0 +1,25 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# SQLite
*.db
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.15.3 create --template minimal --types ts --add sveltekit-adapter="adapter:node" drizzle="database:sqlite+sqlite:better-sqlite3" --no-download-check --install npm ./
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+17
View File
@@ -0,0 +1,17 @@
## Developer Profile
- Senior software engineer / IT architect (+30 years experience)
- Strong backend and system design experience
- Limited hands-on experience with:
- SvelteKit
- FastAPI
- PostgreSQL (modern usage patterns)
- TypeScript ecosystems
## Implications for AI behavior
- Prefer explicit, simple solutions
- Avoid assuming deep framework knowledge
- Provide minimal but correct explanations when needed
- Avoid over-abstracted patterns
- Prioritize clarity over framework idioms
+31
View File
@@ -0,0 +1,31 @@
This is an application to manage Refood volunteers, routes and beneficiaries.
Project main concepts
* Users - The users of the application
* Roles - The roles of the users, there will be 3 main roles:
1. Admin - Can manage users, volunteersshifts and beneficiaries
2. Shift manager - Can manage volunteers and benficiaries
3. Volunteer - Record food delivery to beneficiaries
* Shifts - The shifts are time slots where the food is delivered to beneficiaries
Project name is "RefoodOne" and should be visible in the application, in a non intrusive way.
The logo is the file Refood Rotas-v2.png and use the brand refood colors and fonts in the app theme.
All the interface should be in PT-PT
Has 1 login screen with user and password
Main modules
* Admin (require admin role)
* User
* Shifts
* Volunteers
* Beneficiaries
* Deliveries
* Track delivery
* List deliveries
@@ -0,0 +1,22 @@
# USXX - [Título Curto e Descritivo da User Story]
**Como** [tipo de utilizador / perfil]
**Quero** [desejo / ação que quer realizar]
**Para** [benefício / valor de negócio gerado]
## Descrição do Fluxo
[Breve descrição de como o utilizador interage com esta funcionalidade ou qual o fluxo principal.]
## Critérios de Aceitação
### 1. Interface Gráfica (UI)
- [ ] Detalhes visuais, ecrãs, campos e botões em PT-PT.
- [ ] Validações de formulários (campos obrigatórios, formatos específicos).
### 2. Comportamento e Regras de Negócio
- [ ] Regras que o sistema deve cumprir.
- [ ] Fluxos alternativos ou tratamento de erros.
### 3. Integração de Dados / Segurança
- [ ] Que dados devem ser guardados ou alterados na base de dados SQLite.
- [ ] Permissões de acesso (quem pode aceder/realizar esta ação).
@@ -0,0 +1,19 @@
# US00 - Inicialização do Utilizador Administrador (Seed/Bootstrap)
**Como** Sistema RefoodOne
**Quero** garantir que existe pelo menos um utilizador Administrador inicial na base de dados aquando do primeiro arranque
**Para** permitir que a equipa possa fazer login e começar a gerir o sistema.
## Descrição do Fluxo
Durante a inicialização da base de dados (ou execução das migrações/seed), o sistema deve verificar se já existe algum utilizador registado. Se a base de dados estiver vazia, deve criar automaticamente o utilizador administrador inicial com credenciais pré-definidas.
## Critérios de Aceitação
### 1. Utilizador Administrador Inicial
- **E-mail / Username**: `refoodpdn`
- **Palavra-passe**: `rpdn!2512` (deve ser guardada de forma segura na base de dados usando hash argon2 ou bcrypt)
- **Perfil (Role)**: `admin`
### 2. Comportamento e Regras de Negócio
- O bootstrap deve ocorrer apenas uma vez. Se o utilizador `refoodpdn` já existir, a rotina não deve duplicar o registo nem reescrever a palavra-passe caso esta tenha sido alterada pelo utilizador.
- O processo deve ser automático ao correr as migrações/inicialização do servidor ou através de um script de sementeira (seed) dedicado.
@@ -0,0 +1,27 @@
# US01 - Autenticação (Login)
**Como** utilizador do RefoodOne (Administrador, Gestor de Turno ou Voluntário)
**Quero** introduzir as minhas credenciais (nome de utilizador/email e palavra-passe)
**Para** aceder às funcionalidades específicas do meu perfil/função no sistema.
## Critérios de Aceitação
### 1. Interface de Login (PT-PT)
- Página limpa com o logótipo do Refood e o nome "RefoodOne" visível de forma não intrusiva.
- Campos de entrada:
- **E-mail** (obrigatório)
- **Palavra-passe** (obrigatório, com opção de ocultar/mostrar a palavra-passe)
- Botão de ação: **Entrar**
### 2. Validação e Segurança
- O sistema deve validar as credenciais contra a base de dados SQLite.
- A palavra-passe deve ser verificada de forma segura (usando hash bcrypt/argon2 conforme definido em `.clinrules`).
- Se as credenciais estiverem incorretas:
- Mostrar uma mensagem de erro em PT-PT: *"Utilizador ou palavra-passe incorretos."*
- Não revelar qual dos campos está incorreto por motivos de segurança.
- Cookies de sessão seguros devem ser utilizados após o login com sucesso.
### 3. Redirecionamento por Perfil (Role)
Após o login com sucesso, o utilizador deve ser redirecionado:
- **Administrador**: Redirecionar para a página de gestão de turnos
- **Voluntário**: Redirecionar diretamente para o ecrã de registo/entrega de comida (Entregas).
@@ -0,0 +1,29 @@
# US07 - Agrupamento do Menu de Navegação (Menu Gestão)
**Como** Utilizador autenticado do RefoodOne
**Quero** que as opções "Beneficiários", "Turnos" e "Entregas" sejam agrupadas num submenu sob um menu principal chamado "Gestão"
**Para** melhorar a organização do cabeçalho de navegação e tornar a interface mais limpa e intuitiva.
## Descrição do Fluxo
Ao fazer login na aplicação, o utilizador visualiza um cabeçalho de navegação (navbar) simplificado. Em vez de ter os links principais expostos diretamente, é exibido um item de menu interativo chamado **Gestão**. Ao clicar ou passar o cursor sobre este menu, abre-se um dropdown contendo as opções autorizadas para o perfil do utilizador.
## Critérios de Aceitação
### 1. Interface Gráfica (UI)
- **Menu Gestão**: Deve ser exibido como um menu dropdown na barra de navegação superior, com a etiqueta "Gestão" em PT-PT.
- **Opções do Submenu**: O dropdown deve conter as seguintes opções (quando aplicável ao perfil):
- **Beneficiários**
- **Turnos**
- **Entregas**
- **Estilo**: O dropdown deve seguir a estética Bootstrap 5, com comportamento responsivo (fechamento automático ao clicar fora e suporte a ecrãs táteis/mobile).
### 2. Comportamento e Regras de Negócio
- As opções visíveis no submenu devem respeitar rigorosamente as permissões de cada perfil de utilizador:
- **Administrador (Admin)**: Visualiza todas as opções no submenu (**Beneficiários**, **Turnos**, **Entregas**).
- **Gestor de Turno (Shift Manager)**: Visualiza as opções permitidas (**Turnos**, **Entregas** e **Beneficiários** conforme permissões de gestão).
- **Voluntário (Volunteer)**: Visualiza apenas a opção **Entregas** (ou redirecionado diretamente).
- O submenu só deve ser visível se o utilizador estiver autenticado.
### 3. Integração de Dados / Segurança
- O controlo de visibilidade dos links do submenu deve basear-se no objeto `data.user` carregado pelo layout principal (`+layout.svelte`).
- A proteção de rotas no servidor (`hooks.server.ts`) deve continuar a validar e bloquear acessos diretos caso o utilizador digite o URL manualmente.
@@ -0,0 +1,19 @@
# US08 - Modelo de Dados de Entregas
**Como** Sistema RefoodOne
**Quero** definir a estrutura e modelo de dados para o registo das entregas de cabazes aos beneficiários
**Para** garantir a integridade dos dados e o relacionamento correto entre as entregas, os beneficiários e os turnos.
## Critérios de Aceitação
### 1. Estrutura da Tabela `deliveries`
A tabela de entregas na base de dados SQLite deve conter os seguintes campos:
- **`id`**: Texto (Primary Key, UUID gerado automaticamente, não nulo).
- **`beneficiary_id`**: Texto (não nulo). Chave estrangeira que referencia a tabela `beneficiaries.id`.
- **`shift_id`**: Texto (não nulo). Chave estrangeira que referencia a tabela `shifts.id`.
- **`date`**: Texto (não nulo, formato 'YYYY-MM-DD'). Representa o dia em que a entrega foi efetuada.
- **`created_at`**: Inteiro (não nulo, timestamp). Guarda a data e hora do registo do sistema.
### 2. Integridade Referencial e Comportamento
- **Chave Estrangeira de Beneficiário**: A coluna `beneficiary_id` deve referenciar `beneficiaries(id)`. Deve incluir uma regra de eliminação em cascata (`ON DELETE CASCADE`), de modo a que se um beneficiário for removido, o seu histórico de entregas seja automaticamente apagado.
- **Chave Estrangeira de Turno**: A coluna `shift_id` deve referenciar `shifts(id)`. Deve incluir uma regra de eliminação em cascata (`ON DELETE CASCADE`), de modo a que se um turno for removido do sistema, os registos de entrega a ele associados sejam removidos.
@@ -0,0 +1,40 @@
# US09 - Interface de Registo de Entregas
**Como** Voluntário ou Administrador do RefoodOne
**Quero** um ecrã com botões grandes para cada beneficiário ativo e um popup de confirmação
**Para** registar de forma rápida e tátil as entregas de cabazes em dispositivos tablet.
## Descrição do Fluxo
O voluntário acede ao menu "Entregas". O ecrã apresenta uma grelha de botões táteis correspondentes a todos os beneficiários ativos. Ao tocar no botão de um beneficiário (ex: "#124"), abre-se um popup de confirmação exibindo os dados principais do beneficiário (Nome e Nº de Pessoas do Agregado). Ao clicar em "Confirmar", o sistema grava a entrega associando o turno ativo e a data corrente, e fecha o popup.
## Critérios de Aceitação
### 1. Interface Gráfica (UI)
- **Ecrã de Grelha (Grid)**:
- Apresenta botões organizados em grelha (vários por linha dependendo da resolução).
- Cada botão corresponde a um **beneficiário ativo**.
- O rótulo (label) do botão deve ser o **número do beneficiário** em destaque.
- Os botões devem ser dimensionados para facilitar o toque com o dedo (design tátil ideal para tablets).
- **Popup de Confirmação (Modal)**:
- Disparado ao clicar no botão de um beneficiário.
- Deve mostrar em destaque:
- **Nome do beneficiário**
- **Nº de pessoas do agregado familiar** (do registo do beneficiário)
- Botões de Ação no popup:
- **Confirmar** (submete o registo de entrega, cor verde)
- **Cancelar** (fecha o popup sem registar, cor cinzenta)
### 2. Comportamento e Regras de Negócio
- Apenas beneficiários com estado **Ativo** devem ser exibidos na grelha.
- **Prevenção de Duplicados (Desativação do Botão)**:
- Se um beneficiário já tiver uma entrega registada na data corrente (dia de hoje), o seu respetivo botão na grelha de entregas deve estar **desativado** (disabled).
- O botão desativado deve ter um aspeto visual distinto (ex: cor cinzenta ou verde-suave com um visto de "concluído") para indicar claramente que a entrega já foi efetuada no dia de hoje.
- **Gravação Automática**:
- Ao confirmar, o sistema regista a entrega na base de dados (`deliveries`).
- A data de entrega é preenchida pelo servidor com a **data atual** (no formato YYYY-MM-DD).
- O turno é determinado automaticamente pelo servidor com base no **dia da semana e hora atual** em que o registo está a ser submetido (se cair fora do horário de funcionamento de qualquer turno, associa-se o turno padrão ou mais próximo).
### 3. Integração de Dados / Segurança
- O ecrã deve carregar os dados dos beneficiários e turnos no servidor (`+page.server.ts`).
- A gravação deve ocorrer via SvelteKit Action (`POST` request) para garantir que a data e o turno sejam calculados e validados de forma segura no lado do servidor.
- Apenas utilizadores autenticados podem aceder e registar entregas.
@@ -0,0 +1,26 @@
# US10 - Nome do Beneficiário no Botão de Entrega
**Como** Voluntário do RefoodOne
**Quero** que o primeiro nome do beneficiário seja exibido por baixo do seu número no botão de registo de entrega
**Para** facilitar a identificação visual rápida do beneficiário antes de clicar e reduzir a probabilidade de enganos no registo.
## Descrição do Fluxo
Ao aceder ao ecrã de registo de entregas ("Entregas"), o voluntário vê a grelha de botões táteis. Cada botão agora exibe não apenas o número do beneficiário (ex: "#124"), mas também o seu **primeiro nome** (ex: "João") logo abaixo do número, numa fonte menor e legível.
## Critérios de Aceitação
### 1. Interface Gráfica (UI)
- **Rótulo do Botão (Button Label)**:
- O **Número do Beneficiário** continua a ser exibido em destaque na parte superior do botão.
- O **Primeiro Nome** do beneficiário deve ser exibido logo abaixo do número, centralizado, com estilo de texto menor (ex: classe Bootstrap `small` ou `text-muted`).
- **Tratamento do Nome**:
- Deve ser exibido **apenas o primeiro nome** (a primeira palavra do campo `name` na base de dados). Exemplo: "Maria Eduarda Santos" deve ser exibido como "Maria".
- O texto do nome deve ser ajustado ou truncado se necessário, para garantir que não quebre o layout quadrado do botão tátil.
### 2. Comportamento e Regras de Negócio
- O sistema deve extrair de forma robusta o primeiro nome a partir do nome completo registado na tabela `beneficiaries`.
- Se o campo do nome contiver apenas um nome, exibe esse nome normalmente.
- Se o nome contiver espaços ou hífens, extrai a primeira palavra delimitada por espaços.
### 3. Integração de Dados
- Os dados do beneficiário (`name` e `number`) são os mesmos carregados a partir da tabela `beneficiaries` no servidor. Não são necessárias alterações no modelo de dados da base de dados SQLite.
@@ -0,0 +1,26 @@
# US11 - Ajustes de Navegação (Menu Entregas e Gestão)
**Como** Utilizador do RefoodOne
**Quero** que o menu "Entregas" seja exibido diretamente na barra de navegação principal e que o menu "Gestão" (dropdown) seja visível apenas para Administradores (role `admin`)
**Para** otimizar o acesso rápido dos voluntários e gestores de turno à funcionalidade de entregas, mantendo os painéis administrativos visíveis apenas para os administradores.
## Descrição do Fluxo
Ao fazer login na aplicação:
- Um utilizador com o perfil **Administrador (Admin)** visualiza a opção **Gestão** (que contém os submenus **Beneficiários** e **Turnos**) e, ao lado desta, a opção **Entregas** exposta diretamente.
- Um utilizador com o perfil **Gestor de Turno** ou **Voluntário** visualiza apenas a opção **Entregas** diretamente exposta no cabeçalho principal. O menu **Gestão** fica ocultado para estes utilizadores.
## Critérios de Aceitação
### 1. Interface Gráfica (UI)
- **Menu Entregas**: Deve ser um link principal exposto diretamente no cabeçalho de navegação (navbar), posicionado ao lado do menu Gestão para administradores.
- **Menu Gestão (Dropdown)**:
- Fica visível **apenas** para utilizadores com o perfil de administrador (`role === 'admin'`).
- Passa a conter no seu submenu dropdown apenas as opções **Beneficiários** e **Turnos**.
- **Idioma**: Toda a barra de navegação deve continuar a utilizar termos em PT-PT.
### 2. Comportamento e Regras de Negócio
- Utilizadores com perfil `shift_manager` e `volunteer` não devem ter acesso visual ao menu Gestão.
- A proteção de rotas no servidor (`hooks.server.ts`) deve continuar a garantir que apenas `admin` aceda a `/admin/beneficiarios`, e que apenas `admin` e `shift_manager` acedam a `/admin/turnos`.
### 3. Integração de Dados
- O cabeçalho de navegação (`+layout.svelte`) lê o perfil de utilizador (`data.user.role`) a partir dos dados carregados do servidor para aplicar as regras de visibilidade.
@@ -0,0 +1,28 @@
# US12 - Apagar Registo de Entrega Diário
**Como** Utilizador do RefoodOne (Administrador / Gestor de Turno / Voluntário)
**Quero** poder apagar um registo de entrega efetuado hoje através de um botão na lista de entregas diárias
**Para** corrigir eventuais enganos no registo e permitir que o botão do beneficiário fique novamente ativo para novo registo.
## Descrição do Fluxo
Ao aceder ao ecrã de entregas:
1. O utilizador visualiza a lista "Entregas de Hoje" no fundo da página.
2. Cada linha da tabela de entregas apresenta uma nova coluna com um botão para apagar (ex: ícone de lixo/remover).
3. Ao clicar no botão de apagar, o registo de entrega é eliminado e o botão correspondente do beneficiário na grelha superior volta a ficar disponível (ativo/clicável) imediatamente.
## Critérios de Aceitação
### 1. Interface Gráfica (UI)
- **Tabela de Entregas**:
- Adição de uma nova coluna sem cabeçalho (ou cabeçalho "Ações") no final da tabela.
- Exibição de um botão de eliminação em cada linha (por exemplo, botão com ícone de caixote do lixo em tons vermelhos ou contornos suaves).
- **Grelha de Beneficiários**:
- Após a eliminação com sucesso, o botão do beneficiário correspondente deixa de estar no estado desabilitado/entregue e regressa ao estado ativo original.
### 2. Comportamento e Regras de Negócio
- O botão de apagar elimina definitivamente o registo de entrega da base de dados correspondente àquele dia e beneficiário.
- Deve ser usado um mecanismo reativo ou atualização de página (ex: `use:enhance`) para atualizar imediatamente a tabela de entregas e a grelha de botões de beneficiários sem necessidade de recarregar a página manualmente.
### 3. Integração de Dados / Segurança
- **Base de Dados**: O registo correspondente à entrega deve ser eliminado da tabela `entregas`.
- **Permissões**: Qualquer perfil autenticado com acesso ao ecrã de entregas pode efetuar a eliminação para retificar erros de registo imediatos.
@@ -0,0 +1,42 @@
# US13 - Gestão de Utilizadores
**Como** Administrador do RefoodOne
**Quero** poder visualizar e gerir as contas de utilizadores (voluntários, gestores de turno e administradores) do sistema
**Para** controlar os perfis de acesso e atualizar as credenciais dos utilizadores de forma segura.
## Descrição do Fluxo
Ao aceder ao sistema como administrador:
1. O utilizador encontra um novo submenu **Utilizadores** no menu dropdown principal **Gestão**.
2. Ao selecionar **Utilizadores**, é direcionado para o ecrã de listagem de utilizadores.
3. No ecrã de listagem, pode ver uma tabela contendo o **Nome de Utilizador** e o **Perfil (Role)** de cada conta registada. Cada linha tem uma opção/link para aceder ao detalhe do utilizador.
4. No ecrã de detalhe de um utilizador, o administrador pode:
- Alterar o **Nome de Utilizador**.
- Alterar o **Perfil** (selecionando numa lista dropdown as opções: *Administrador*, *Gestor de Turno*, *Voluntário*).
- Definir uma nova password na secção correspondente, inserindo a nova password e confirmando a mesma num segundo campo (não sendo necessária a password antiga).
5. O administrador clica em Guardar para persistir as alterações na base de dados.
## Critérios de Aceitação
### 1. Interface Gráfica (UI)
- **Menu Gestão**: Adicionar a ligação para "Utilizadores" no menu dropdown do cabeçalho principal.
- **Ecrã de Listagem**:
- Tabela com colunas: **Nome** (`username`) e **Perfil** (`role`).
- Botão ou ligação para editar o detalhe em cada linha de utilizador.
- **Ecrã de Detalhe**:
- Campo de texto para o **Nome de Utilizador**.
- Dropdown com as roles suportadas em PT-PT:
- *Administrador* (`admin`)
- *Gestor de Turno* (`shift_manager`)
- *Voluntário* (`volunteer`)
- Secção "Alterar Password" contendo dois campos de texto/password: **Nova Password** e **Confirmar Nova Password**.
- Mensagens de erro/sucesso claras em português.
### 2. Comportamento e Regras de Negócio
- Apenas utilizadores com a role `admin` podem aceder a estes ecrãs (tanto a listagem como o detalhe).
- Se a secção de password for preenchida, o sistema deve validar se as duas passwords coincidem e se cumprem requisitos mínimos de segurança (ex: tamanho mínimo).
- Se a secção de password for deixada em branco, o utilizador é guardado mantendo a password antiga sem alterações.
- O ecrã de detalhe também deve permitir criar um utilizador novo (ou ter um fluxo correspondente) com os mesmos campos.
### 3. Integração de Dados / Segurança
- **Segurança**: As novas passwords devem ser encriptadas de forma segura (ex: hashing) antes de guardar na tabela `users` da base de dados.
- **Controlo de Acesso**: Bloqueio de acesso a nível de rotas no servidor (`hooks.server.ts` ou páginas locais) para utilizadores que não tenham perfil `admin`.
@@ -0,0 +1,32 @@
# US14 - Homepage com Botões de Atalho (Tablet-Optimized)
**Como** Utilizador do RefoodOne
**Quero** ter uma página inicial simples com botões táteis de grande dimensão e ícones ilustrativos
**Para** aceder de forma rápida e intuitiva às funcionalidades autorizadas a partir de um tablet.
## Descrição do Fluxo
Ao iniciar a aplicação e fazer login:
1. O utilizador é direcionado para a Homepage (`/`).
2. Dependendo do seu perfil de utilizador, são apresentados botões táteis grandes organizados numa grelha responsiva:
- **Voluntário** e **Gestor de Turno**: Visualizam apenas o botão **Entregas** (com ícone de cabaz/cesto de compras).
- **Administrador**: Visualiza três botões: **Entregas** (ícone de cabaz), **Utilizadores** (ícone de pessoas/contas) e **Beneficiários** (ícone de grupo/lista).
3. Ao clicar num dos botões grandes, o utilizador é encaminhado para a respetiva secção do sistema.
## Critérios de Aceitação
### 1. Interface Gráfica (UI)
- **Design para Tablet**:
- Grelha centrada no ecrã com botões de tamanho generoso (ex: cartões táteis com pelo menos 140x140px), facilmente clicáveis com um dedo.
- Efeitos visuais modernos ao passar o rato (hover) ou tocar no ecrã.
- **Botões e Ícones**:
- **Entregas**: Rótulo "Entregas", com um ícone representativo de cabaz/cesto (SVG).
- **Beneficiários**: Rótulo "Beneficiários", com um ícone representativo de lista/pessoas (SVG).
- **Utilizadores**: Rótulo "Utilizadores", com um ícone representativo de utilizadores/chaves (SVG).
- **Idioma**: Todos os rótulos e textos em PT-PT.
### 2. Comportamento e Regras de Negócio
- A visualização dos botões deve respeitar rigorosamente o perfil do utilizador autenticado (`data.user.role`).
- Utilizadores sem o perfil `admin` (ex: `volunteer` e `shift_manager`) não devem ver os botões de administração (Beneficiários e Utilizadores) no ecrã inicial.
### 3. Integração de Dados
- A página lê a informação do utilizador a partir dos dados locais carregados (`data.user`) na rota raiz (`/`).
@@ -0,0 +1,33 @@
# US02 - Listar Beneficiários (Admin)
**Como** Administrador do RefoodOne
**Quero** visualizar a lista de todos os beneficiários registados no sistema
**Para** poder consultar os seus dados e gerir a atribuição de apoio alimentar.
## Descrição do Fluxo
O Administrador acede ao menu "Admin" -> "Beneficiários", onde lhe é apresentada uma tabela com a listagem de todos os beneficiários registados. A listagem permite pesquisar e filtrar os beneficiários de forma rápida.
## Critérios de Aceitação
### 1. Interface Gráfica (UI)
- **Tabela de Beneficiários**: Deve conter as seguintes colunas:
- **Número** (Número de beneficiário)
- **Nome** (Nome do beneficiário)
- **Nº Pessoas** (Número de pessoas do agregado familiar)
- **Estado** (Ativo / Inativo)
- **Ações** (Botões para Editar/Inativar/Ver Detalhes)
- **Filtros e Pesquisa**:
- Barra de pesquisa por Nome ou número
- Filtro por Estado (Todos, Ativos, Inativos).
- **Botão "Novo Beneficiário"**: Um botão destacado (ex: cor primária/verde) posicionado no topo da página para criar um novo beneficiário.
- **Paginação**: Caso existam muitos beneficiários, a tabela deve ser paginada (ex: 15 por página).
### 2. Comportamento e Regras de Negócio
- Apenas utilizadores com a função **Admin** podem aceder a esta página.
- Se um utilizador sem permissões tentar aceder, o sistema deve apresentar uma página de erro 403 (Acesso Negado) ou redirecionar para a página inicial com mensagem de erro.
- A listagem deve ser ordenada alfabeticamente por Nome por defeito.
### 3. Integração de Dados / Segurança
- Os dados devem ser lidos diretamente da tabela de beneficiários da base de dados SQLite.
- A consulta à base de dados deve ser executada exclusivamente no servidor (`+page.server.ts`).
@@ -0,0 +1,37 @@
# US03 - Detalhe e Edição de Beneficiário (Admin)
**Como** Administrador do RefoodOne
**Quero** visualizar, criar e editar as informações de um beneficiário específico
**Para** manter os dados dos beneficiários atualizados no sistema.
## Descrição do Fluxo
Este ecrã serve dois propósitos:
1. **Criação**: Acedido através do botão "Novo Beneficiário" na listagem (US02), abrindo o formulário vazio para preenchimento.
2. **Edição/Visualização**: Acedido através do botão de ação "Editar" na listagem (US02), abrindo o formulário pré-preenchido com os dados do beneficiário selecionado.
## Critérios de Aceitação
### 1. Interface Gráfica (UI)
- **Campos do Formulário**:
- **Número** (obrigatório, numérico único - identificador do beneficiário no sistema)
- **Nome** (obrigatório, texto)
- **Contacto** (obrigatório, número de telefone/telemóvel válido)
- **Nº Pessoas do Agregado** (obrigatório, numérico, mínimo 1)
- **Observações** (opcional, área de texto livre para notas adicionais)
- **Estado** (Ativo / Inativo - apenas na edição)
- **Botões de Ação**:
- **Guardar** (submete o formulário e guarda os dados)
- **Cancelar** (volta para a listagem sem guardar alterações)
### 2. Comportamento e Regras de Negócio
- Apenas utilizadores com a função **Admin** podem aceder/gravar neste ecrã.
- **Validações**:
- O campo **Número** deve ser único na base de dados. Se o Administrador tentar guardar um número já existente, o sistema deve exibir um aviso claro em PT-PT.
- Todos os campos obrigatórios devem ser validados antes de permitir guardar.
- **Redirecionamento**:
- Após guardar com sucesso (inserir ou atualizar), o sistema deve redirecionar o utilizador de volta para a listagem de beneficiários (US02) com uma mensagem de sucesso (ex: *"Beneficiário guardado com sucesso"*).
- Clicar em Cancelar deve redirecionar de volta para a listagem (US02) sem efetuar qualquer alteração.
### 3. Integração de Dados / Segurança
- As operações de escrita (INSERT/UPDATE) devem ser efetuadas de forma segura na base de dados SQLite através do Drizzle ORM.
- O processamento do formulário deve ser feito inteiramente no servidor (`+page.server.ts` actions).
@@ -0,0 +1,32 @@
# US04 - Listar Turnos (Admin)
**Como** Administrador ou Gestor de Turnos do RefoodOne
**Quero** visualizar a lista de todos os turnos de entregas registados no sistema
**Para** consultar o seu horário atual e os dias em que são realizados.
## Descrição do Fluxo
O utilizador acede ao menu de administração correspondente aos "Turnos", onde lhe é apresentada uma tabela com os turnos configurados no sistema (ex: T1, T2, T3), indicando os seus horários e os dias da semana em que ocorrem.
## Critérios de Aceitação
### 1. Interface Gráfica (UI)
- **Tabela de Turnos**: Deve conter as seguintes colunas:
- **Identificador** (ex: T1, T2, T3)
- **Horário** (ex: 14h30 - 16h30)
- **Duração** (ex: 2h)
- **Dias de Funcionamento** (ex: Terças e Quintas)
- **Ações** (Botão para Editar)
- **Idioma**: Todos os textos e cabeçalhos devem estar em PT-PT.
### 2. Comportamento e Regras de Negócio
- Apenas utilizadores com as funções de **Admin** ou **Gestor de Turnos** podem aceder a esta listagem.
- Por defeito, o sistema deve apresentar os turnos configurados atualmente:
- **T1**: 14h30 - 16h30
- **T2**: 16h30 - 18h30
- **T3**: 18h30 - 20h30
- Por defeito, os dias de funcionamento são as **3ªs feiras (Terças)** e **5ªs feiras (Quintas)**.
- A listagem deve ser ordenada por ordem cronológica de horário de início.
### 3. Integração de Dados / Segurança
- Os dados devem ser lidos da base de dados SQLite através das tabelas de configuração/turnos do sistema.
- A consulta à base de dados deve ser executada exclusivamente no servidor (`+page.server.ts`).
@@ -0,0 +1,34 @@
# US05 - Editar Horário e Dias de Turno (Admin)
**Como** Administrador ou Gestor de Turnos do RefoodOne
**Quero** editar o horário (horas de início e fim) e os dias da semana de um turno específico
**Para** adaptar o funcionamento das entregas conforme a disponibilidade dos voluntários e as necessidades da Refood PdN.
## Descrição do Fluxo
O utilizador clica no botão "Editar" de um turno específico na listagem de turnos (US04). É apresentado um formulário pré-preenchido onde pode alterar a hora de início, a hora de fim e selecionar/deselecionar os dias da semana em que o turno é realizado.
## Critérios de Aceitação
### 1. Interface Gráfica (UI)
- **Campos do Formulário**:
- **Identificador** (Apenas leitura/desativado, ex: "T1")
- **Hora de Início** (Obrigatório, seletor de tempo no formato HH:MM)
- **Hora de Fim** (Obrigatório, seletor de tempo no formato HH:MM)
- **Dias da Semana** (Obrigatório, checkboxes permitindo selecionar múltiplos dias, ex: Segunda a Domingo)
- **Botões de Ação**:
- **Guardar** (Submete o formulário e grava as alterações)
- **Cancelar** (Volta para a listagem sem guardar)
### 2. Comportamento e Regras de Negócio
- Apenas utilizadores com as funções de **Admin** ou **Gestor de Turnos** podem aceder a este ecrã e guardar alterações.
- **Validações**:
- A hora de fim deve ser posterior à hora de início.
- A duração configurada deve respeitar as regras do sistema (ex: 2 horas por turno por defeito, ou emitir um aviso/validação caso não seja).
- Pelo menos um dia da semana deve ser selecionado (não é permitido ter um turno ativo sem dias associados).
- **Redirecionamento**:
- Após guardar com sucesso, redirecionar para a listagem de turnos (US04) com uma mensagem de sucesso em PT-PT (ex: *"Horário do turno guardado com sucesso"*).
- Clicar em Cancelar redireciona de volta para a listagem (US04) sem efetuar qualquer alteração.
### 3. Integração de Dados / Segurança
- As alterações de horário e dias devem ser guardadas com segurança na base de dados SQLite através do Drizzle ORM.
- O processamento da ação do formulário deve ocorrer no servidor (`+page.server.ts` actions).
@@ -0,0 +1,36 @@
# US06 - Inicialização (Bootstrap) de Turnos
**Como** Sistema do RefoodOne
**Quero** inicializar a base de dados com os turnos predefinidos (T1, T2 e T3)
**Para** garantir que a aplicação começa a funcionar com uma configuração base válida e sem necessidade de introdução manual de dados.
## Descrição do Fluxo
Este é um processo automatizado executado pelo sistema (ex: através de um script de *seeding* ou na inicialização da base de dados). O sistema verifica se a tabela de turnos está vazia e, caso esteja, insere os três turnos base com os horários e dias da semana padrão.
## Critérios de Aceitação
### 1. Interface Gráfica (UI)
- Não aplicável (processo de sistema em segundo plano).
### 2. Comportamento e Regras de Negócio
- **Verificação de Existência**: O bootstrap apenas deve ser executado se a tabela de turnos na base de dados SQLite estiver completamente vazia. Se já existirem dados, o processo não deve fazer nada (para evitar sobrescrever alterações manuais feitas pelo Administrador).
- **Dados Padrão a Inserir**:
- **Turno T1**:
- Código/Identificador: `T1`
- Hora de Início: `14:30`
- Hora de Fim: `16:30`
- Dias da Semana: Terça-feira (3ªf) e Quinta-feira (5ªf)
- **Turno T2**:
- Código/Identificador: `T2`
- Hora de Início: `16:30`
- Hora de Fim: `18:30`
- Dias da Semana: Terça-feira (3ªf) e Quinta-feira (5ªf)
- **Turno T3**:
- Código/Identificador: `T3`
- Hora de Início: `18:30`
- Hora de Fim: `20:30`
- Dias da Semana: Terça-feira (3ªf) e Quinta-feira (5ªf)
### 3. Integração de Dados / Segurança
- A inserção dos dados deve ser feita de forma segura utilizando o Drizzle ORM num script de seed (`seed.ts` ou semelhante) ou num mecanismo executado durante o arranque do servidor.
- Os dias da semana devem ser armazenados num formato estruturado (ex: JSON array ou tabela de junção) para fácil manipulação pelas US04 e US05.
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
dialect: 'sqlite',
dbCredentials: { url: process.env.DATABASE_URL },
verbose: true,
strict: false
});
+4254
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "refood-one",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev --host",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"vite": "^8.0.7"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.8.0",
"bootstrap": "^5.3.8"
}
}
+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');
}
};
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+20
View File
@@ -0,0 +1,20 @@
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
kit: {
adapter: adapter(),
typescript: {
config: (config) => ({
...config,
include: [...config.include, '../drizzle.config.ts']
})
}
}
};
export default config;
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+6
View File
@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});