feat: bootstrap project
This commit is contained in:
+123
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Drizzle
|
||||||
|
DATABASE_URL=local.db
|
||||||
+25
@@ -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
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
});
|
||||||
Generated
+4254
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+19
@@ -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 {};
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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 |
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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())
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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.')}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user