Compare commits

..

6 Commits

Author SHA1 Message Date
Duarte 1133775c85 corrigir erro na edição do beneficiário 2026-06-04 20:20:06 +01:00
Duarte 1d88b4cb1a alterar para ser pwa 2026-06-04 19:12:04 +01:00
Duarte 521987f824 script par carregar dados dummy no servidor 2026-06-04 18:55:26 +01:00
Duarte 8f51987342 Ecrão das ausências 2026-06-04 18:51:22 +01:00
Duarte 6ddc8974e0 chore: update robots.txt to block crawling and add SETUP_SERVER.md 2026-06-04 12:42:28 +01:00
Duarte b1cc6368a1 Integração com Odoo 2026-06-03 01:13:58 +01:00
32 changed files with 6950 additions and 72 deletions
+7
View File
@@ -1,2 +1,9 @@
# Drizzle
DATABASE_URL=local.db
# Odoo API
ODOO_URL=
ODOO_DB=
ODOO_KEY=
ODOO_USER=
ODOO_COMPANY_ID=
+274
View File
@@ -0,0 +1,274 @@
# Manual de Configuração do Servidor Ubuntu 24.04 — Refood-One
Este guia contém todos os passos necessários para preparar um servidor limpo com Ubuntu 24.04 para alojar e executar a aplicação **Refood-One** (SvelteKit + Drizzle/SQLite).
Por questões de segurança, a aplicação será executada sob um utilizador de sistema dedicado (`refood`) sem privilégios de superutilizador.
---
## 1. Atualizar o Servidor e Instalar Dependências de Compilação
Como a aplicação utiliza módulos nativos de Node.js (como `better-sqlite3` e `bcrypt`), precisamos das ferramentas de compilação instaladas:
```bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl git build-essential sqlite3
```
---
## 2. Instalar o Node.js LTS (Versão 22 ou 20)
Configurar o repositório NodeSource e instalar o Node.js:
```bash
# Adicionar o repositório do Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# Confirmar as versões instaladas
node -v
npm -v
```
---
## 3. Criar Utilizador Dedicado para a Aplicação (Segurança)
Nunca execute a aplicação como utilizador `root`. Crie um utilizador de sistema sem palavra-passe e com pasta própria:
```bash
sudo adduser --system --group --home /var/www/refood-one refood
```
---
## 4. Configurar o Diretório da Aplicação e Dependências
1. Faça o clone do repositório da aplicação (como root/administrador):
```bash
# Clonar para a pasta dedicada
git clone <URL_DO_REPOSITORIO_GIT> /var/www/refood-one
```
2. Ajuste as permissões para que o utilizador `refood` seja o proprietário exclusivo dos ficheiros:
```bash
sudo chown -R refood:refood /var/www/refood-one
```
3. Mude para o diretório da aplicação:
```bash
cd /var/www/refood-one
```
4. Instale as dependências. Recomenda-se realizar a instalação como o utilizador `refood`:
```bash
sudo -u refood npm install
```
---
## 5. Configurar as Variáveis de Ambiente
Crie o ficheiro `.env` de produção:
```bash
sudo -u refood nano /var/www/refood-one/.env
```
Adicione e adapte as configurações necessárias:
```env
# Porta onde o servidor SvelteKit irá correr localmente
PORT=3000
ORIGIN=https://o-seu-dominio.com
# Drizzle / SQLite
DATABASE_URL=local.db
# Odoo API
ODOO_URL="https://erp.onrefood.com/jsonrpc"
ODOO_DB="refood.flybyodoo.pt"
ODOO_KEY="b4dadfb997fa8e3735c52b8d28476cd5ca339d2e"
ODOO_USER="duarte.caldas.oliveira@gmail.com"
ODOO_COMPANY_ID=
```
---
## 6. Inicializar Base de Dados e Compilar o Projeto
Execute estes comandos para aplicar as tabelas na base de dados SQLite e construir a build de produção da aplicação SvelteKit:
```bash
# Executar as migrações/push do Drizzle (injetando a variável de ambiente necessária)
sudo -u refood DATABASE_URL=local.db npm run db:push
# Criar a build de produção da aplicação
sudo -u refood npm run build
```
---
## 7. Instalar e Configurar o PM2 (Process Manager)
O PM2 irá manter o processo Node.js ativo e iniciá-lo no arranque do sistema.
1. Instalar o PM2 globalmente:
```bash
sudo npm install -y -g pm2
```
2. Criar o ficheiro de configuração do PM2 (`ecosystem.config.cjs`) para carregar o ficheiro `.env` e as variáveis necessárias no arranque:
```bash
sudo -u refood nano /var/www/refood-one/ecosystem.config.cjs
```
Cole o seguinte conteúdo:
```javascript
const fs = require('fs');
const path = require('path');
// Ler e parsear o ficheiro .env
const envPath = path.resolve(__dirname, '.env');
const env = {};
if (fs.existsSync(envPath)) {
const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
for (const line of lines) {
if (!line.trim() || line.trim().startsWith('#')) continue;
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
if (match) {
const key = match[1];
let value = match[2] || '';
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
env[key] = value.trim();
}
}
}
module.exports = {
apps: [
{
name: 'refood-one',
script: 'build/index.js',
cwd: '/var/www/refood-one',
env: {
NODE_ENV: 'production',
...env
}
}
]
};
```
3. Iniciar a aplicação usando a configuração criada:
```bash
sudo -u refood pm2 start /var/www/refood-one/ecosystem.config.cjs
```
4. Configurar o PM2 para iniciar no arranque do servidor com o utilizador `refood`:
```bash
sudo pm2 startup systemd -u refood --hp /var/www/refood-one
```
*Nota: O comando acima irá gerar um comando `sudo env PATH=$PATH...` na consola. Copie e execute esse comando gerado para ativar o serviço no systemd.*
5. Guardar a lista de processos para o utilizador `refood`:
```bash
sudo -u refood pm2 save
```
---
## 8. Configurar o Nginx como Reverse Proxy
1. Instalar o Nginx:
```bash
sudo apt install -y nginx
```
2. Criar um ficheiro de configuração do site:
```bash
sudo nano /etc/nginx/sites-available/refood-one
```
3. Colar a seguinte configuração (substituindo `o-seu-dominio.com` pelo seu domínio real):
```nginx
server {
listen 80;
server_name o-seu-dominio.com www.o-seu-dominio.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
4. Ativar a configuração e reiniciar o Nginx:
```bash
# Criar o link simbólico para ativar
sudo ln -s /etc/nginx/sites-available/refood-one /etc/nginx/sites-enabled/
# Desativar a configuração por defeito do Nginx (opcional)
sudo rm /etc/nginx/sites-enabled/default
# Verificar se a sintaxe do Nginx está correta
sudo nginx -t
# Recarregar o Nginx
sudo systemctl restart nginx
```
---
## 9. Configurar Certificado SSL (HTTPS) com Let's Encrypt
1. Instalar o Certbot:
```bash
sudo apt install -y certbot python3-certbot-nginx
```
2. Obter o certificado SSL (o Certbot irá alterar a configuração do Nginx automaticamente):
```bash
sudo certbot --nginx -d o-seu-dominio.com -d www.o-seu-dominio.com
```
---
## Anexo: Limpeza do PM2 Root Antigo
Se configurou anteriormente o PM2 a correr diretamente como `root` e deseja remover esses vestígios do servidor atual, execute:
```bash
# 1. Parar o serviço systemctl antigo do root
sudo systemctl stop pm2-root
sudo systemctl disable pm2-root
# 2. Remover o script de arranque antigo do root
sudo pm2 unstartup systemd
# 3. Remover o ficheiro do serviço systemd
sudo rm /etc/systemd/system/pm2-root.service
sudo systemctl daemon-reload
sudo systemctl reset-failed
# 4. Matar o processo daemon residual do root
sudo pm2 kill
```
---
## Anexo B: Como Atualizar a Aplicação (Deploy de Novas Versões)
Sempre que fizer alterações no Git e as quiser colocar em produção, execute a seguinte sequência de comandos no servidor:
```bash
# 1. Mudar para a pasta da aplicação
cd /var/www/refood-one
# 2. Descarregar a última versão do código (como utilizador refood)
sudo -u refood git pull
# 3. Instalar novas dependências (caso package.json tenha alterado)
sudo -u refood npm install
# 4. Aplicar novas migrações na base de dados (se o schema do Drizzle tiver mudado)
sudo -u refood DATABASE_URL=local.db npm run db:push
# 5. Compilar o projeto novamente
sudo -u refood npm run build
# 6. Recarregar o PM2 de forma segura (Zero Downtime) e atualizar variáveis de ambiente
sudo -u refood pm2 reload refood-one --update-env
```
*Dica: O comando `pm2 reload` com a flag `--update-env` reinicia os processos de forma faseada, garantindo que o seu site não fica fora do ar (zero downtime) ao mesmo tempo que carrega as novas alterações feitas no ficheiro `.env`.*
+2 -3
View File
@@ -26,6 +26,5 @@ Main modules
* Track delivery
* List deliveries
## Integrações Externas
* **Beneficiários**: O repositório dos beneficiários é a API json-rpc da Odoo v14.
+61
View File
@@ -0,0 +1,61 @@
CREATE TABLE `beneficiaries` (
`id` text PRIMARY KEY NOT NULL,
`number` integer,
`name` text NOT NULL,
`contact` text NOT NULL,
`household_size` integer DEFAULT 1 NOT NULL,
`observations` text,
`status` text DEFAULT 'ativo' NOT NULL,
`odoo_id` integer,
`odoo_number` text,
`parent_odoo_id` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `odoo_id_idx` ON `beneficiaries` (`odoo_id`);--> statement-breakpoint
CREATE INDEX `number_idx` ON `beneficiaries` (`number`);--> statement-breakpoint
CREATE TABLE `deliveries` (
`id` text PRIMARY KEY NOT NULL,
`beneficiary_id` text NOT NULL,
`shift_id` text NOT NULL,
`date` text NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiaries`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`shift_id`) REFERENCES `shifts`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `shifts` (
`id` text PRIMARY KEY NOT NULL,
`code` text NOT NULL,
`start_time` text NOT NULL,
`end_time` text NOT NULL,
`days` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `shifts_code_unique` ON `shifts` (`code`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`role` text NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
+404
View File
@@ -0,0 +1,404 @@
{
"version": "6",
"dialect": "sqlite",
"id": "08266197-0b38-43f0-a6c0-19ac410008f8",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"beneficiaries": {
"name": "beneficiaries",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"number": {
"name": "number",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"household_size": {
"name": "household_size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"observations": {
"name": "observations",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'ativo'"
},
"odoo_id": {
"name": "odoo_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"odoo_number": {
"name": "odoo_number",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"parent_odoo_id": {
"name": "parent_odoo_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"odoo_id_idx": {
"name": "odoo_id_idx",
"columns": [
"odoo_id"
],
"isUnique": true
},
"number_idx": {
"name": "number_idx",
"columns": [
"number"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"deliveries": {
"name": "deliveries",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"beneficiary_id": {
"name": "beneficiary_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"shift_id": {
"name": "shift_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"deliveries_beneficiary_id_beneficiaries_id_fk": {
"name": "deliveries_beneficiary_id_beneficiaries_id_fk",
"tableFrom": "deliveries",
"tableTo": "beneficiaries",
"columnsFrom": [
"beneficiary_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deliveries_shift_id_shifts_id_fk": {
"name": "deliveries_shift_id_shifts_id_fk",
"tableFrom": "deliveries",
"tableTo": "shifts",
"columnsFrom": [
"shift_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"shifts": {
"name": "shifts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"code": {
"name": "code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"start_time": {
"name": "start_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_time": {
"name": "end_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"days": {
"name": "days",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"shifts_code_unique": {
"name": "shifts_code_unique",
"columns": [
"code"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1780444363958,
"tag": "0000_light_red_wolf",
"breakpoints": true
}
]
}
+4663
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -13,7 +13,8 @@
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"db:seed": "node scripts/generate-dummy.js"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.4",
@@ -22,6 +23,7 @@
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24",
"@vite-pwa/sveltekit": "^1.1.0",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2",
"svelte": "^5.55.2",
+140
View File
@@ -0,0 +1,140 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
// 1. Load DATABASE_URL from .env
let dbPath = 'local.db';
try {
const envContent = fs.readFileSync('.env', 'utf8');
const match = envContent.match(/^DATABASE_URL=(.+)$/m);
if (match && match[1]) {
dbPath = match[1].trim().replace(/['"]/g, '');
}
} catch (e) {
console.log('Aviso: .env não encontrado ou erro ao ler. Usando local.db por defeito.');
}
console.log(`A ligar à base de dados em: ${dbPath}`);
const db = new Database(dbPath);
try {
// Começar transação
db.transaction(() => {
// 1. Limpar a tabela de entregas (deliveries)
console.log('A limpar tabela de entregas (deliveries)...');
db.prepare('DELETE FROM deliveries').run();
// 2. Garantir que existe pelo menos um turno (shift)
let shift = db.prepare("SELECT * FROM shifts LIMIT 1").get();
if (!shift) {
console.log('Nenhum turno encontrado. A criar turno padrão T1...');
const nowTs = Date.now();
const shiftId = crypto.randomUUID();
db.prepare(`
INSERT INTO shifts (id, code, start_time, end_time, days, created_at, updated_at)
VALUES (?, 'T1', '14:30', '16:30', '2,4', ?, ?)
`).run(shiftId, nowTs, nowTs);
shift = db.prepare("SELECT * FROM shifts LIMIT 1").get();
}
if (!shift) {
throw new Error('Não foi possível encontrar ou criar um turno padrão.');
}
console.log(`Turno utilizado: ${shift.code} (ID: ${shift.id})`);
// 3. Garantir que os beneficiários de teste #1, #2, #3 existem e estão ativos/parent
const testCreationTime = Date.now() - 35 * 24 * 60 * 60 * 1000;
for (const num of [1, 2, 3]) {
const existing = db.prepare('SELECT * FROM beneficiaries WHERE number = ?').get(num);
if (!existing) {
const bId = crypto.randomUUID();
console.log(`A criar Beneficiário Teste #${num}...`);
db.prepare(`
INSERT INTO beneficiaries (id, number, name, contact, household_size, status, is_parent, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'ativo', 1, ?, ?)
`).run(
bId,
num,
`Beneficiário Teste #${num}`,
`91234560${num}`,
num,
testCreationTime,
Date.now()
);
} else {
console.log(`A atualizar Beneficiário Teste #${num}...`);
db.prepare(`
UPDATE beneficiaries
SET status = 'ativo', is_parent = 1, created_at = ?, updated_at = ?
WHERE id = ?
`).run(testCreationTime, Date.now(), existing.id);
}
}
// 4. Buscar todos os beneficiários ativos que são chefes de família (is_parent = 1)
const activeBeneficiaries = db.prepare(`
SELECT * FROM beneficiaries
WHERE status = 'ativo' AND is_parent = 1
`).all();
console.log(`Encontrados ${activeBeneficiaries.length} beneficiários ativos (chefes de família).`);
// 5. Calcular as terças (2) e quintas (4) nos últimos 30 dias
const dates = [];
const today = new Date();
const todayStr = today.toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
for (let i = 1; i <= 30; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
const dayOfWeek = d.getDay();
if (dayOfWeek === 2 || dayOfWeek === 4) {
const dateStr = d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
if (dateStr < todayStr) {
dates.push(dateStr);
}
}
}
dates.sort();
if (dates.length === 0) {
throw new Error('Não foram encontradas datas de entregas válidas nos últimos 30 dias.');
}
console.log(`Datas calculadas para entregas (Terças/Quintas dos últimos 30 dias):`, dates);
// 6. Inserir entregas de teste
let insertedCount = 0;
for (const b of activeBeneficiaries) {
let datesToInsert = [...dates];
// Logica para criar diferentes níveis de ausências nos dados de teste
if (b.number === 1) {
datesToInsert = dates.slice(1);
} else if (b.number === 2) {
datesToInsert = dates.slice(2);
} else if (b.number === 3) {
datesToInsert = dates.slice(3);
}
for (const dateStr of datesToInsert) {
const deliveryId = crypto.randomUUID();
db.prepare(`
INSERT INTO deliveries (id, beneficiary_id, shift_id, date, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(deliveryId, b.id, shift.id, dateStr, Date.now());
insertedCount++;
}
}
console.log(`Sucesso! Foram geradas ${insertedCount} entregas para os beneficiários ativos.`);
})();
} catch (err) {
console.error('Erro ao gerar dados de teste:', err);
process.exit(1);
}
+5
View File
@@ -4,6 +4,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<meta name="theme-color" content="#FCB515" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/pwa-192x192.png" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+1 -1
View File
@@ -47,7 +47,7 @@ export const handle: Handle = async ({ event, resolve }) => {
const path = event.url.pathname;
// Route protection
if (path.startsWith('/admin')) {
if (path.startsWith('/admin') || path.startsWith('/super')) {
if (!event.locals.user) {
throw redirect(303, `/login?redirectTo=${encodeURIComponent(path)}`);
}
+44
View File
@@ -43,6 +43,50 @@ try {
console.error("Migration failed:", err);
}
// Run schema migration if 'odoo_id', 'parent_odoo_id' or 'odoo_number' column is missing in beneficiaries
try {
const tableInfo = client.prepare("PRAGMA table_info(beneficiaries)").all() as any[];
const hasOdooId = tableInfo.some((col) => col.name === 'odoo_id');
const hasParentOdooId = tableInfo.some((col) => col.name === 'parent_odoo_id');
const hasOdooNumber = tableInfo.some((col) => col.name === 'odoo_number');
if (!hasOdooId) {
console.log("Migrating beneficiaries table: adding 'odoo_id' column...");
client.prepare("ALTER TABLE beneficiaries ADD COLUMN odoo_id INTEGER").run();
}
if (!hasParentOdooId) {
console.log("Migrating beneficiaries table: adding 'parent_odoo_id' column...");
client.prepare("ALTER TABLE beneficiaries ADD COLUMN parent_odoo_id INTEGER").run();
}
if (!hasOdooNumber) {
console.log("Migrating beneficiaries table: adding 'odoo_number' column...");
client.prepare("ALTER TABLE beneficiaries ADD COLUMN odoo_number TEXT").run();
}
// Create unique index if it doesn't exist
client.prepare("CREATE UNIQUE INDEX IF NOT EXISTS odoo_id_idx ON beneficiaries(odoo_id)").run();
console.log("Database migration for beneficiaries table completed.");
} catch (err) {
console.error("Migration for beneficiaries failed:", err);
}
// Run schema migration to create settings table if missing
try {
client.prepare(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`).run();
console.log("Database migration for settings table completed.");
} catch (err) {
console.error("Migration for settings failed:", err);
}
export const db = drizzle(client, { schema });
// Seed admin user if the users table is empty (US00)
+19 -3
View File
@@ -1,4 +1,4 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
@@ -19,15 +19,24 @@ export const sessions = sqliteTable('sessions', {
export const beneficiaries = sqliteTable('beneficiaries', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
number: integer('number').notNull().unique(),
number: integer('number'),
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'
odooId: integer('odoo_id'),
odooNumber: text('odoo_number'),
parentOdooId: integer('parent_odoo_id'),
isParent: integer('is_parent', { mode: 'boolean' }),
createdAt: integer('created_at').notNull().$defaultFn(() => Date.now()),
updatedAt: integer('updated_at').notNull().$defaultFn(() => Date.now())
});
}, (table) => [
uniqueIndex('odoo_id_idx').on(table.odooId),
index('number_idx').on(table.number)
]);
export const shifts = sqliteTable('shifts', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
@@ -51,3 +60,10 @@ export const deliveries = sqliteTable('deliveries', {
createdAt: integer('created_at').notNull().$defaultFn(() => Date.now())
});
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: integer('updated_at').notNull().$defaultFn(() => Date.now())
});
+261
View File
@@ -0,0 +1,261 @@
import { env } from '$env/dynamic/private';
/**
* Authenticates with the Odoo external API and returns the user's UID.
*/
export async function authenticateOdoo(): Promise<number> {
const url = env.ODOO_URL || 'https://erp.onrefood.com/jsonrpc';
const db = env.ODOO_DB;
const user = env.ODOO_USER;
const key = env.ODOO_KEY;
if (!db || !user || !key) {
throw new Error('Faltam parâmetros de configuração do Odoo no ambiente (.env): ODOO_DB, ODOO_USER, ODOO_KEY');
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
service: 'common',
method: 'authenticate',
args: [db, user, key, {}]
},
id: 1
})
});
if (!response.ok) {
throw new Error(`Erro na chamada HTTP do Odoo: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(`Erro retornado pela API do Odoo: ${JSON.stringify(data.error)}`);
}
// In Odoo, if authentication fails, it returns false (not throwing jsonrpc error always)
const uid = data.result;
if (uid === false || uid === undefined || uid === null) {
throw new Error('Credenciais do Odoo incorretas (UID não retornado).');
}
return uid;
}
/**
* Syncs beneficiaries from Odoo.
*/
import { db } from './db';
import * as schema from './db/schema';
import { eq, sql } from 'drizzle-orm';
export async function syncBeneficiaries(): Promise<{ success: boolean; count: number; error?: string }> {
try {
const url = env.ODOO_URL || 'https://erp.onrefood.com/jsonrpc';
const dbName = env.ODOO_DB;
const user = env.ODOO_USER;
const key = env.ODOO_KEY;
if (!dbName || !user || !key) {
throw new Error('Faltam parâmetros de configuração do Odoo no ambiente (.env): ODOO_DB, ODOO_USER, ODOO_KEY');
}
// 1. Authenticate to get UID
const uid = await authenticateOdoo();
// 2. Get last sync date from database settings
const lastSyncSetting = db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, 'last_beneficiary_sync'))
.get();
const lastSyncTime = lastSyncSetting ? lastSyncSetting.value : '2026-01-01 00:00:00';
const companyIdRaw = env.ODOO_COMPANY_ID;
const companyId = companyIdRaw ? parseInt(companyIdRaw, 10) : 107;
// 3. Make execute_kw call to search_read beneficiaries
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
service: 'object',
method: 'execute_kw',
args: [
dbName,
uid,
key,
'res.beneficiary',
'search_read',
[[
['write_date', '>=', lastSyncTime]
]],
{
context: {
allowed_company_ids: [companyId],
active_test: false
}
}
]
},
id: 2
})
});
if (!response.ok) {
throw new Error(`Erro na chamada HTTP do Odoo search_read: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(`Erro retornado pela API do Odoo search_read: ${JSON.stringify(data.error)}`);
}
const results = data.result;
if (!Array.isArray(results)) {
throw new Error('Formato inválido de resposta do Odoo (result não é um array)');
}
let syncCount = 0;
const now = Date.now();
// Helper to extract trailing digits as integer
const extractNumber = (odooNum: string | null | undefined): number | null => {
if (!odooNum) return null;
const match = odooNum.match(/\d+$/);
return match ? parseInt(match[0], 10) : null;
};
// 4. Process and save/update each beneficiary
for (const result of results) {
const odooId = result.id;
const name = result.name || '';
const contact = result.phone || result.mobile || '';
const odooNumber = result.number || '';
const parsedNumber = extractNumber(odooNumber);
const parentOdooId = Array.isArray(result.parent_id) ? result.parent_id[0] : null;
const isParent = parentOdooId === null;
const activeStatus = result.active !== false ? 'ativo' : 'inativo';
const beneficiaryNumber = isParent ? parsedNumber : null;
// Check if beneficiary with odooId already exists
const existingById = db
.select()
.from(schema.beneficiaries)
.where(eq(schema.beneficiaries.odooId, odooId))
.get();
if (existingById) {
// Update existing
db.update(schema.beneficiaries)
.set({
name,
contact,
number: beneficiaryNumber,
odooNumber,
parentOdooId,
isParent,
status: activeStatus,
updatedAt: now
})
.where(eq(schema.beneficiaries.id, existingById.id))
.run();
} else {
// Insert new
db.insert(schema.beneficiaries)
.values({
number: beneficiaryNumber,
name,
contact,
odooId,
odooNumber,
parentOdooId,
isParent,
status: activeStatus,
createdAt: now,
updatedAt: now
})
.run();
}
syncCount++;
}
// Update household size for all main beneficiaries (parentOdooId is null)
// householdSize = 1 + count of dependents (other beneficiaries with parentOdooId pointing to this odooId)
db.run(
sql`
UPDATE beneficiaries
SET household_size = 1 + (
SELECT COUNT(*)
FROM beneficiaries AS dependents
WHERE dependents.parent_odoo_id = beneficiaries.odoo_id
)
WHERE parent_odoo_id IS NULL AND odoo_id IS NOT NULL
`
);
// Set household size of dependents (parentOdooId is not null) to 1
db.run(
sql`
UPDATE beneficiaries
SET household_size = 1
WHERE parent_odoo_id IS NOT NULL AND odoo_id IS NOT NULL
`
);
// 5. Update last sync date to current time in UTC format (YYYY-MM-DD HH:MM:SS)
const formatUTC = (date: Date): string => {
const pad = (n: number) => n.toString().padStart(2, '0');
return (
date.getUTCFullYear() +
'-' +
pad(date.getUTCMonth() + 1) +
'-' +
pad(date.getUTCDate()) +
' ' +
pad(date.getUTCHours()) +
':' +
pad(date.getUTCMinutes()) +
':' +
pad(date.getUTCSeconds())
);
};
const newSyncTime = formatUTC(new Date());
// Upsert settings table
const hasSetting = db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, 'last_beneficiary_sync'))
.get();
if (hasSetting) {
db.update(schema.settings)
.set({ value: newSyncTime, updatedAt: now })
.where(eq(schema.settings.key, 'last_beneficiary_sync'))
.run();
} else {
db.insert(schema.settings)
.values({ key: 'last_beneficiary_sync', value: newSyncTime, updatedAt: now })
.run();
}
return { success: true, count: syncCount };
} catch (error: any) {
console.error('Erro na sincronização de beneficiários:', error);
return { success: false, count: 0, error: error.message || 'Erro desconhecido' };
}
}
+6 -2
View File
@@ -1,7 +1,11 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
export const load: LayoutServerLoad = async ({ locals, url }) => {
const hostname = url.hostname;
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
return {
user: locals.user
user: locals.user,
isLocalhost
};
};
+33
View File
@@ -2,6 +2,7 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import favicon from '$lib/assets/favicon.svg';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let { data, children } = $props();
@@ -9,6 +10,14 @@
// @ts-ignore
import('bootstrap/dist/js/bootstrap.bundle.min.js');
}
onMount(async () => {
if (browser && 'serviceWorker' in navigator) {
// @ts-ignore
const { registerSW } = await import('virtual:pwa-register');
registerSW({ immediate: true });
}
});
</script>
<svelte:head>
@@ -42,6 +51,9 @@
<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/ausencias">Ausências</a>
</li>
<li>
<a class="dropdown-item py-2 px-3 rounded-2" href="/admin/turnos">Turnos</a>
</li>
@@ -54,6 +66,27 @@
<li class="nav-item">
<a class="nav-link px-3 rounded-2" href="/entregas">Entregas</a>
</li>
{#if data.user.role === 'admin' && data.isLocalhost}
<li class="nav-item dropdown">
<button type="button" class="nav-link dropdown-toggle px-3 rounded-2 bg-transparent border-0 text-warning fw-bold" id="superDropdown" data-bs-toggle="dropdown" aria-expanded="false">
Super
</button>
<ul class="dropdown-menu border-0 shadow mt-2" aria-labelledby="superDropdown">
<li>
<span class="dropdown-item-text text-muted small py-2 px-3 fw-semibold">Ferramentas Super</span>
</li>
<li>
<a class="dropdown-item py-2 px-3 rounded-2" href="/super/dummy-data">Dummy Data</a>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<span class="dropdown-item py-2 px-3 text-secondary small">Acesso Localhost Ativo</span>
</li>
</ul>
</li>
{/if}
</ul>
<div class="d-flex align-items-center gap-3">
+11
View File
@@ -37,6 +37,17 @@
<span class="card-label fw-bold text-dark fs-4">Beneficiários</span>
</a>
<!-- Ausências Card (Admin only) -->
<a href="/admin/ausencias" 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">
<!-- Calendar X / Absences Icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-calendar-x-fill" viewBox="0 0 16 16">
<path d="M4 .5a.5.5 0 0 0-1 0V1H2a2 2 0 0 0-2 2v1h16V3a2 2 0 0 0-2-2h-1V.5a.5.5 0 0 0-1 0V1H4zm12 5H0v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2zM6.854 8.146 8 9.293l1.146-1.147a.5.5 0 1 1 .708.708L8.707 10l1.147 1.146a.5.5 0 0 1-.708.708L8 10.707l-1.146 1.147a.5.5 0 0 1-.708-.708L7.293 10 6.146 8.854a.5.5 0 1 1 .708-.708"/>
</svg>
</div>
<span class="card-label fw-bold text-dark fs-4">Ausências</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">
+144
View File
@@ -0,0 +1,144 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq, and, gte, lte, sql } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const search = url.searchParams.get('search') || '';
const minAbsencesStr = url.searchParams.get('minAbsences') || '1';
const minAbsences = parseInt(minAbsencesStr, 10) || 1;
// Define timezone-adjusted default date boundaries
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(today.getDate() - 30);
const defaultStartDate = thirtyDaysAgo.toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
const defaultEndDate = yesterday.toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
const startDate = url.searchParams.get('startDate') || defaultStartDate;
const endDate = url.searchParams.get('endDate') || defaultEndDate;
try {
// 1. Get all active parent beneficiaries
const activeBeneficiaries = db
.select()
.from(schema.beneficiaries)
.where(
and(
eq(schema.beneficiaries.status, 'ativo'),
eq(schema.beneficiaries.isParent, true)
)
)
.all();
// 3. Find all unique delivery dates where at least one delivery occurred in the selected range
const deliveryDatesResult = db
.select({
date: schema.deliveries.date
})
.from(schema.deliveries)
.where(
and(
gte(schema.deliveries.date, startDate),
lte(schema.deliveries.date, endDate)
)
)
.all();
const deliveryDates = Array.from(new Set(deliveryDatesResult.map((r) => r.date))).sort();
// 4. Fetch all deliveries in the selected range
const recentDeliveries = db
.select()
.from(schema.deliveries)
.where(
and(
gte(schema.deliveries.date, startDate),
lte(schema.deliveries.date, endDate)
)
)
.all();
// 5. Fetch the last delivery date of all time for each beneficiary
const lastDeliveries = db
.select({
beneficiaryId: schema.deliveries.beneficiaryId,
lastDate: sql<string>`max(${schema.deliveries.date})`
})
.from(schema.deliveries)
.groupBy(schema.deliveries.beneficiaryId)
.all();
const lastDeliveryMap = new Map(lastDeliveries.map((d) => [d.beneficiaryId, d.lastDate]));
// Helper to convert timestamp to date string
const timestampToDateStr = (timestamp: number): string => {
return new Date(timestamp).toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
};
// 6. Map and process absences per beneficiary
let list = activeBeneficiaries.map((b) => {
const registeredDateStr = timestampToDateStr(b.createdAt);
// Only delivery dates on/after registration date are relevant
const relevantDeliveryDates = deliveryDates.filter((date) => date >= registeredDateStr);
// Actual deliveries received by this beneficiary
const receivedDates = recentDeliveries
.filter((d) => d.beneficiaryId === b.id)
.map((d) => d.date);
// Missed delivery dates
const missedDates = relevantDeliveryDates.filter((date) => !receivedDates.includes(date));
return {
...b,
totalRelevantDeliveries: relevantDeliveryDates.length,
receivedCount: receivedDates.length,
absencesCount: missedDates.length,
missedDates: missedDates.reverse(), // Show latest missed dates first
lastDelivery: lastDeliveryMap.get(b.id) || null
};
});
// 7. Filter by minimum absences (default >= 1)
list = list.filter((b) => b.absencesCount >= minAbsences);
// 8. Apply search filters (name/number/contact)
if (search) {
const searchLower = search.toLowerCase();
list = list.filter(
(b) =>
b.name.toLowerCase().includes(searchLower) ||
(b.number !== null && b.number.toString().includes(searchLower)) ||
(b.contact && b.contact.includes(searchLower))
);
}
// Sort by absences count descending, then by number ascending
list.sort((a, b) => b.absencesCount - a.absencesCount || (a.number || 0) - (b.number || 0));
return {
beneficiaries: list,
totalDeliveryDays: deliveryDates.length,
search,
minAbsences,
startDate,
endDate
};
} catch (err) {
console.error('Error loading absences data:', err);
return {
beneficiaries: [],
totalDeliveryDays: 0,
search,
minAbsences,
startDate,
endDate,
error: 'Erro ao processar o histórico de ausências dos beneficiários.'
};
}
};
+237
View File
@@ -0,0 +1,237 @@
<script lang="ts">
let { data } = $props();
function formatDate(dateStr: string): string {
if (!dateStr) return '';
try {
const [year, month, day] = dateStr.split('-');
const date = new Date(Number(year), Number(month) - 1, Number(day));
const formatted = date.toLocaleDateString('pt-PT', { weekday: 'short', day: 'numeric', month: 'short' });
// Capitalize weekday
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
} catch {
return dateStr;
}
}
function calculateAssiduity(received: number, total: number): number {
if (total === 0) return 100;
return Math.round((received / total) * 100);
}
</script>
<svelte:head>
<title>Ausências de Beneficiários - RefoodOne</title>
</svelte:head>
<div class="container py-4">
<div class="mb-4">
<h2 class="fw-bold text-dark mb-1">Ausências de Beneficiários</h2>
<p class="text-muted mb-0">Controlo de faltas e assiduidade no período selecionado (entregas 2 vezes por semana)</p>
</div>
{#if data.error}
<div class="alert alert-danger border-0 shadow-sm rounded-3 mb-4" role="alert">
{data.error}
</div>
{/if}
<!-- Summary Statistics Cards -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-0 shadow-sm rounded-4 p-3 bg-white h-100">
<div class="d-flex align-items-center gap-3">
<div class="stat-icon bg-success-subtle text-success rounded-3 p-3 d-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-calendar-check" viewBox="0 0 16 16">
<path d="M10.854 7.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 9.793l2.646-2.647a.5.5 0 0 1 .708 0"/>
<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>
</div>
<div>
<h6 class="text-secondary fw-semibold small mb-1">Dias de Entrega Ocorridos</h6>
<h3 class="fw-bold text-dark mb-0">{data.totalDeliveryDays} dias</h3>
<span class="text-muted small">no período selecionado</span>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm rounded-4 p-3 bg-white h-100">
<div class="d-flex align-items-center gap-3">
<div class="stat-icon bg-danger-subtle text-danger rounded-3 p-3 d-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-person-exclamation" viewBox="0 0 16 16">
<path d="M11 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 13zm9.998-1.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0m-.5-3.875a.41.41 0 0 0-.406.416v1.854a.41.41 0 0 0 .812 0V8.041a.41.41 0 0 0-.406-.416"/>
</svg>
</div>
<div>
<h6 class="text-secondary fw-semibold small mb-1">Beneficiários com Faltas</h6>
<h3 class="fw-bold text-dark mb-0">{data.beneficiaries.length}</h3>
<span class="text-muted small">ativos e com faltas registadas</span>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm rounded-4 p-3 bg-white h-100">
<div class="d-flex align-items-center gap-3">
<div class="stat-icon bg-warning-subtle text-warning-emphasis rounded-3 p-3 d-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-percent" viewBox="0 0 16 16">
<path d="M13.485 1.431a.5.5 0 0 1 .37.662l-9.2 13.5a.5.5 0 1 1-.84-.572l9.2-13.5a.5.5 0 0 1 .67-.19zM4.15 2.5a2 2 0 1 1-2.757 2.915A2 2 0 0 1 4.15 2.5zm0 1a1 1 0 1 0-1.378 1.458A1 1 0 0 0 4.15 3.5zm7.7 7.5a2 2 0 1 1-2.757 2.915A2 2 0 0 1 11.85 11zm0 1a1 1 0 1 0-1.378 1.458A1 1 0 0 0 11.85 12.5z"/>
</svg>
</div>
<div>
<h6 class="text-secondary fw-semibold small mb-1">Filtro de Relevância</h6>
<h3 class="fw-bold text-dark mb-0">{data.minAbsences} {data.minAbsences === 1 ? 'falta' : 'faltas'}</h3>
<span class="text-muted small">limiar selecionado para aviso</span>
</div>
</div>
</div>
</div>
</div>
<!-- Filters & Search -->
<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-4">
<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-6 col-lg-2">
<label for="minAbsences" class="form-label fw-semibold text-secondary small">Mínimo de Faltas</label>
<select name="minAbsences" id="minAbsences" value={data.minAbsences.toString()} class="form-select rounded-3 border-2">
<option value="1">1 ou mais faltas</option>
<option value="2">2 ou mais faltas</option>
<option value="3">3 ou mais faltas</option>
<option value="4">4 ou mais faltas</option>
</select>
</div>
<div class="col-md-4 col-lg-2">
<label for="startDate" class="form-label fw-semibold text-secondary small">Data de Início</label>
<input
type="date"
name="startDate"
id="startDate"
value={data.startDate}
class="form-control rounded-3 border-2"
/>
</div>
<div class="col-md-4 col-lg-2">
<label for="endDate" class="form-label fw-semibold text-secondary small">Data de Fim</label>
<input
type="date"
name="endDate"
id="endDate"
value={data.endDate}
class="form-control rounded-3 border-2"
/>
</div>
<div class="col-md-4 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>
<!-- Main Absences Table -->
<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">Relatório de Assiduidade</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" style="width: 220px;">Nome</th>
<th class="py-3" style="width: 150px;">Contacto</th>
<th class="py-3 text-center" style="width: 120px;">Faltas / Entregas</th>
<th class="py-3 text-center" style="width: 120px;">Assiduidade</th>
<th class="py-3">Dias com Ausência no Período</th>
<th class="py-3" style="width: 140px;">Última Entrega</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="8" class="text-center py-5 text-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="currentColor" class="bi bi-check2-circle mb-2 text-success" viewBox="0 0 16 16">
<path d="M2.5 8a5.5 5.5 0 0 1 8.25-4.764.5.5 0 0 0 .5-.866A6.5 6.5 0 1 0 14.5 8a.5.5 0 0 0-1 0 5.5 5.5 0 1 1-11 0"/>
<path d="M15.354 3.354a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0z"/>
</svg>
<p class="mb-0 fw-semibold">Nenhum beneficiário com ausências registadas.</p>
</td>
</tr>
{:else}
{#each data.beneficiaries as b}
{@const assid = calculateAssiduity(b.receivedCount, b.totalRelevantDeliveries)}
<tr>
<td class="px-4 fw-bold text-secondary">
#{b.number}
</td>
<td class="fw-semibold text-dark">
{b.name}
</td>
<td class="text-secondary small">
{b.contact || 'Sem contacto'}
</td>
<td class="text-center fw-semibold">
<span class="text-danger">{b.absencesCount}</span> / {b.totalRelevantDeliveries}
</td>
<td class="text-center">
<span class="badge rounded-pill fw-bold text-uppercase px-2.5 py-1.5 border {assid >= 75 ? 'bg-success-subtle text-success border-success-subtle' : assid >= 50 ? 'bg-warning-subtle text-warning border-warning-subtle' : 'bg-danger-subtle text-danger border-danger-subtle'}" style="font-size: 0.7rem;">
{assid}%
</span>
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{#each b.missedDates as date}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fw-semibold rounded-2 px-2 py-1" style="font-size: 0.75rem;">
{formatDate(date)}
</span>
{/each}
</div>
</td>
<td class="text-secondary small fw-medium">
{b.lastDelivery ? formatDate(b.lastDelivery) : 'Nunca'}
</td>
<td class="px-4 text-end">
<a href="/admin/beneficiarios/{b.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>
Ficha
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
<style>
.stat-icon {
width: 48px;
height: 48px;
}
.badge {
letter-spacing: 0.02em;
}
</style>
+23 -2
View File
@@ -8,7 +8,10 @@ export const load: PageServerLoad = async ({ url }) => {
const status = url.searchParams.get('status') || '';
try {
let query = db.select().from(schema.beneficiaries);
let query = db
.select()
.from(schema.beneficiaries)
.where(eq(schema.beneficiaries.isParent, true));
// 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.
@@ -19,7 +22,7 @@ export const load: PageServerLoad = async ({ url }) => {
list = list.filter(
(b) =>
b.name.toLowerCase().includes(searchLower) ||
b.number.toString().includes(searchLower) ||
(b.number !== null && b.number.toString().includes(searchLower)) ||
(b.contact && b.contact.includes(searchLower))
);
}
@@ -43,3 +46,21 @@ export const load: PageServerLoad = async ({ url }) => {
};
}
};
import { syncBeneficiaries } from '$lib/server/odoo';
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
sync: async ({ locals }) => {
if (locals.user?.role !== 'admin') {
return fail(403, { error: 'Acesso negado. Apenas administradores podem sincronizar com o Odoo.' });
}
const result = await syncBeneficiaries();
if (!result.success) {
return fail(500, { error: result.error || 'Erro ao sincronizar com o Odoo.' });
}
return { success: true, count: result.count };
}
};
+151 -12
View File
@@ -1,11 +1,46 @@
<script lang="ts">
import { page } from '$app/state';
import { enhance } from '$app/forms';
let { data } = $props();
let { data, form } = $props();
let syncing = $state(false);
let sortBy = $state<'number' | 'name' | 'householdSize'>('number');
let sortOrder = $state<'asc' | 'desc'>('asc');
function toggleSort(field: 'number' | 'name' | 'householdSize') {
if (sortBy === field) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortBy = field;
sortOrder = 'asc';
}
}
const sortedBeneficiaries = $derived.by(() => {
const list = [...(data.beneficiaries || [])];
return list.sort((a, b) => {
let valA = a[sortBy];
let valB = b[sortBy];
if (valA === null || valA === undefined) return 1;
if (valB === null || valB === undefined) return -1;
if (typeof valA === 'string' && typeof valB === 'string') {
return sortOrder === 'asc'
? valA.localeCompare(valB, 'pt-PT')
: valB.localeCompare(valA, 'pt-PT');
} else {
return sortOrder === 'asc'
? (valA as number) - (valB as number)
: (valB as number) - (valA as number);
}
});
});
const successMessage = $derived(page.url.searchParams.get('success'));
</script>
<svelte:head>
@@ -24,17 +59,58 @@
</div>
{/if}
{#if form?.success}
<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>
Sincronização concluída com sucesso! {form.count} beneficiários atualizados/importados.
</div>
</div>
{/if}
{#if form?.error}
<div class="alert alert-danger 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-exclamation-triangle-fill text-danger" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
<div>
{form.error}
</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 class="d-flex gap-2">
{#if data.user?.role === 'admin'}
<form method="POST" action="?/sync" use:enhance={() => {
syncing = true;
return async ({ update }) => {
await update();
syncing = false;
};
}} onsubmit={(e) => { if (!confirm('Tem a certeza que deseja sincronizar os beneficiários com o Odoo?')) e.preventDefault(); }}>
<button type="submit" class="btn btn-outline-sync btn-lg d-flex align-items-center gap-2 rounded-3 shadow-sm" disabled={syncing}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-arrow-repeat {syncing ? 'spin' : ''}" viewBox="0 0 16 16">
<path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/>
<path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/>
</svg>
{syncing ? 'A sincronizar...' : 'Sincronizar Odoo'}
</button>
</form>
{/if}
<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>
<div class="card border-0 shadow-sm rounded-4 p-3 mb-4 bg-white">
@@ -69,20 +145,44 @@
</div>
<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 d-flex justify-content-between align-items-center flex-wrap gap-2">
<h5 class="fw-bold text-dark mb-0">Lista de Beneficiários</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</th>
<th class="px-4 py-3 sortable-header" style="width: 120px;" onclick={() => toggleSort('number')}>
<div class="d-flex align-items-center gap-1 select-none">
Número
{#if sortBy === 'number'}
<span class="sort-arrow">{sortOrder === 'asc' ? '▲' : '▼'}</span>
{/if}
</div>
</th>
<th class="py-3 sortable-header" onclick={() => toggleSort('name')}>
<div class="d-flex align-items-center gap-1 select-none">
Nome
{#if sortBy === 'name'}
<span class="sort-arrow">{sortOrder === 'asc' ? '▲' : '▼'}</span>
{/if}
</div>
</th>
<th class="py-3">Contacto</th>
<th class="py-3 text-center" style="width: 120px;">Agregado</th>
<th class="py-3 text-center sortable-header" style="width: 120px;" onclick={() => toggleSort('householdSize')}>
<div class="d-flex align-items-center justify-content-center gap-1 select-none">
Agregado
{#if sortBy === 'householdSize'}
<span class="sort-arrow">{sortOrder === 'asc' ? '▲' : '▼'}</span>
{/if}
</div>
</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}
{#if sortedBeneficiaries.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">
@@ -92,7 +192,7 @@
</td>
</tr>
{:else}
{#each data.beneficiaries as beneficiary}
{#each sortedBeneficiaries as beneficiary}
<tr>
<td class="px-4 fw-bold text-secondary">
#{beneficiary.number}
@@ -129,3 +229,42 @@
</div>
</div>
</div>
<style>
.sortable-header {
cursor: pointer;
transition: background-color 0.15s ease-in-out;
}
.sortable-header:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.select-none {
user-select: none;
}
.sort-arrow {
font-size: 0.75rem;
color: var(--bs-primary, #2c562e);
}
.btn-outline-sync {
color: #2c562e;
border-color: #2c562e;
background-color: transparent;
transition: all 0.2s ease-in-out;
}
.btn-outline-sync:hover:not(:disabled) {
color: #ffffff;
background-color: #2c562e;
border-color: #2c562e;
}
.spin {
animation: spin-animation 1s infinite linear;
}
@keyframes spin-animation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -74,14 +74,15 @@ export const actions: Actions = {
}
try {
// Check if new number conflicts with another beneficiary (excluding self)
// Check if new number conflicts with another beneficiary (excluding self and dependents)
const existingConflict = db
.select()
.from(schema.beneficiaries)
.where(
and(
eq(schema.beneficiaries.number, number),
ne(schema.beneficiaries.id, id)
ne(schema.beneficiaries.id, id),
eq(schema.beneficiaries.isParent, true)
)
)
.get();
@@ -1,6 +1,6 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { eq, and } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
@@ -39,11 +39,16 @@ export const actions: Actions = {
}
try {
// Check if number is unique
// Check if number is unique among parent/main beneficiaries
const existing = db
.select()
.from(schema.beneficiaries)
.where(eq(schema.beneficiaries.number, number))
.where(
and(
eq(schema.beneficiaries.number, number),
eq(schema.beneficiaries.isParent, true)
)
)
.get();
if (existing) {
+34 -8
View File
@@ -1,22 +1,28 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq, and, asc } from 'drizzle-orm';
import { eq, and, asc, isNull } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
export const load: PageServerLoad = async ({ url, locals }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
const todayStr = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' }); // 'YYYY-MM-DD'
const selectedDate = url.searchParams.get('date') || todayStr;
try {
// Fetch active beneficiaries sorted by their unique number
// Fetch active beneficiaries sorted by their unique number, who are main beneficiaries (no parentOdooId)
const activeBeneficiaries = db
.select()
.from(schema.beneficiaries)
.where(eq(schema.beneficiaries.status, 'ativo'))
.where(
and(
eq(schema.beneficiaries.status, 'ativo'),
isNull(schema.beneficiaries.parentOdooId)
)
)
.orderBy(asc(schema.beneficiaries.number))
.all();
@@ -27,7 +33,7 @@ export const load: PageServerLoad = async ({ locals }) => {
.orderBy(asc(schema.shifts.startTime))
.all();
// Fetch today's deliveries with associated beneficiary and shift details
// Fetch deliveries for the selected date with associated beneficiary and shift details
const todayDeliveries = db
.select({
id: schema.deliveries.id,
@@ -39,17 +45,19 @@ export const load: PageServerLoad = async ({ locals }) => {
.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))
.where(eq(schema.deliveries.date, selectedDate))
.all();
// Compile the list of beneficiary IDs who already received a basket today
// Compile the list of beneficiary IDs who already received a basket
const deliveredIds = todayDeliveries.map((d) => d.beneficiary.id);
return {
beneficiaries: activeBeneficiaries,
shifts: shiftsList,
todayDeliveries,
deliveredIds
deliveredIds,
selectedDate,
todayStr
};
} catch (err) {
console.error('Error loading deliveries page:', err);
@@ -58,6 +66,8 @@ export const load: PageServerLoad = async ({ locals }) => {
shifts: [],
todayDeliveries: [],
deliveredIds: [],
selectedDate,
todayStr,
error: 'Erro ao carregar os dados de entregas.'
};
}
@@ -179,6 +189,22 @@ export const actions: Actions = {
}
try {
// Get the delivery to verify its date
const delivery = db
.select()
.from(schema.deliveries)
.where(eq(schema.deliveries.id, deliveryId))
.get();
if (!delivery) {
return fail(404, { error: 'Registo de entrega não encontrado.' });
}
const todayStr = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
if (delivery.date !== todayStr) {
return fail(400, { error: 'Não é permitido apagar registos de dias passados.' });
}
// Delete the delivery record
db.delete(schema.deliveries)
.where(eq(schema.deliveries.id, deliveryId))
+95 -31
View File
@@ -6,12 +6,14 @@
let activeModalBeneficiary = $state<any>(null);
let activeDeleteDelivery = $state<any>(null);
let isLoading = $state(false);
let dateInput = $state<HTMLInputElement | null>(null);
// Derived list of delivered IDs so UI updates dynamically
const deliveredSet = $derived(new Set(data.deliveredIds || []));
const isToday = $derived(data.selectedDate === data.todayStr);
function openConfirmation(beneficiary: any) {
if (deliveredSet.has(beneficiary.id)) return;
if (deliveredSet.has(beneficiary.id) || !isToday) return;
activeModalBeneficiary = beneficiary;
}
@@ -39,6 +41,17 @@
return '';
}
}
function formatDateLong(dateStr: string): string {
if (!dateStr) return '';
try {
const [year, month, day] = dateStr.split('-');
const date = new Date(Number(year), Number(month) - 1, Number(day));
return date.toLocaleDateString('pt-PT', { timeZone: 'Europe/Lisbon', weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
} catch {
return dateStr;
}
}
</script>
<svelte:head>
@@ -49,14 +62,32 @@
<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>
{#if isToday}
<p class="text-muted mb-0">Selecione o número do beneficiário para registar a entrega do cabaz de hoje</p>
{:else}
<p class="text-muted mb-0">Visualização de registos de entrega para a data selecionada (alterações desabilitadas)</p>
{/if}
</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 class="position-relative d-inline-block">
<!-- original style and layout wrapper -->
<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 text-capitalize">{formatDateLong(data.selectedDate)}</span>
</div>
<!-- Overlay input overlayed on top -->
<form method="GET" class="position-absolute top-0 start-0 w-100 h-100 m-0 opacity-0" style="z-index: 5;">
<input
type="date"
name="date"
value={data.selectedDate}
onchange={(e) => e.currentTarget.form?.submit()}
class="datepicker-overlay w-100 h-100 border-0 cursor-pointer position-absolute top-0 start-0"
style="cursor: pointer; z-index: 5;"
/>
</form>
</div>
</div>
@@ -68,7 +99,17 @@
<!-- 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>
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<h5 class="fw-bold text-secondary mb-0">Selecione o Beneficiário</h5>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-success-subtle text-success border border-success-subtle px-2.5 py-1 rounded-pill" style="font-size: 0.75rem;">
{deliveredSet.size} entregues
</span>
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle px-2.5 py-1 rounded-pill" style="font-size: 0.75rem;">
{data.beneficiaries.length - deliveredSet.size} por entregar
</span>
</div>
</div>
{#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">
@@ -82,17 +123,17 @@
{@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}
class="btn-grid-beneficiary d-flex flex-column align-items-center justify-content-center rounded-4 border-2 transition shadow-sm {isDelivered ? 'delivered' : isToday ? 'active' : 'disabled-past'}"
onclick={() => isToday && openConfirmation(beneficiary)}
disabled={isDelivered || !isToday}
aria-label="Beneficiário número {beneficiary.number} - {beneficiary.name}"
>
<span class="beneficiary-number fw-bold mb-0">{beneficiary.number}</span>
<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">
<span class="delivery-status-icon mt-1">
<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>
@@ -107,7 +148,7 @@
<!-- 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>
<h5 class="fw-bold text-dark mb-0">{isToday ? 'Entregas de Hoje' : 'Entregas Registadas'}</h5>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
@@ -117,14 +158,16 @@
<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>
{#if isToday}
<th class="py-3 px-4 text-end" style="width: 100px;">Ações</th>
{/if}
</tr>
</thead>
<tbody>
{#if data.todayDeliveries.length === 0}
<tr>
<td colspan="5" class="text-center py-4 text-muted">
Nenhum cabaz entregue hoje.
<td colspan={isToday ? 5 : 4} class="text-center py-4 text-muted">
Nenhum cabaz entregue nesta data.
</td>
</tr>
{:else}
@@ -144,19 +187,21 @@
<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>
{#if isToday}
<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>
{/if}
</tr>
{/each}
{/if}
@@ -322,6 +367,13 @@
cursor: not-allowed;
}
.btn-grid-beneficiary.disabled-past {
background-color: #f8f9fa;
border-color: #dee2e6;
color: #adb5bd;
cursor: not-allowed;
}
.beneficiary-number {
font-size: 1.5rem;
line-height: 1;
@@ -330,4 +382,16 @@
.transition {
transition: all 0.15s ease-in-out;
}
.datepicker-overlay::-webkit-calendar-picker-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
</style>
+2 -1
View File
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import bcrypt from 'bcrypt';
import { dev } from '$app/environment';
export const load: PageServerLoad = async ({ locals }) => {
// If already logged in, redirect them
@@ -67,7 +68,7 @@ export const actions: Actions = {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: true,
secure: !dev,
maxAge: 60 * 60 * 24 * 7 // 7 days in seconds
});
+151
View File
@@ -0,0 +1,151 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw redirect(303, '/login');
}
return {};
};
export const actions: Actions = {
generate: async ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
return fail(403, { error: 'Não autorizado.' });
}
try {
// 1. Clear the deliveries table
db.delete(schema.deliveries).run();
// 2. Ensure at least one shift exists
let shift = db.select().from(schema.shifts).get();
if (!shift) {
const nowTs = Date.now();
db.insert(schema.shifts)
.values({
code: 'T1',
startTime: '14:30',
endTime: '16:30',
days: '2,4',
createdAt: nowTs,
updatedAt: nowTs
})
.run();
shift = db.select().from(schema.shifts).get();
}
if (!shift) {
return fail(500, { error: 'Não foi possível encontrar ou criar um turno padrão.' });
}
// 3. Ensure test beneficiaries #1, #2, #3 exist
const testCreationTime = Date.now() - 35 * 24 * 60 * 60 * 1000;
for (const num of [1, 2, 3]) {
const existing = db
.select()
.from(schema.beneficiaries)
.where(eq(schema.beneficiaries.number, num))
.get();
if (!existing) {
db.insert(schema.beneficiaries)
.values({
number: num,
name: `Beneficiário Teste #${num}`,
contact: `91234560${num}`,
householdSize: num,
status: 'ativo',
isParent: true,
createdAt: testCreationTime,
updatedAt: Date.now()
})
.run();
} else {
db.update(schema.beneficiaries)
.set({
status: 'ativo',
isParent: true,
createdAt: testCreationTime,
updatedAt: Date.now()
})
.where(eq(schema.beneficiaries.id, existing.id))
.run();
}
}
// 4. Fetch all active parent beneficiaries
const activeBeneficiaries = db
.select()
.from(schema.beneficiaries)
.where(
and(
eq(schema.beneficiaries.status, 'ativo'),
eq(schema.beneficiaries.isParent, true)
)
)
.all();
// 5. Calculate Tuesdays & Thursdays in the last 30 days
const dates: string[] = [];
const today = new Date();
const todayStr = today.toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
for (let i = 1; i <= 30; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
const dayOfWeek = d.getDay();
if (dayOfWeek === 2 || dayOfWeek === 4) {
const dateStr = d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
if (dateStr < todayStr) {
dates.push(dateStr);
}
}
}
dates.sort();
if (dates.length === 0) {
return fail(400, { error: 'Não foram encontradas datas de entregas válidas nos últimos 30 dias.' });
}
let insertedCount = 0;
for (const b of activeBeneficiaries) {
let datesToInsert = [...dates];
if (b.number === 1) {
datesToInsert = dates.slice(1);
} else if (b.number === 2) {
datesToInsert = dates.slice(2);
} else if (b.number === 3) {
datesToInsert = dates.slice(3);
}
for (const dateStr of datesToInsert) {
db.insert(schema.deliveries)
.values({
beneficiaryId: b.id,
shiftId: shift.id,
date: dateStr,
createdAt: Date.now()
})
.run();
insertedCount++;
}
}
return {
success: true,
totalDays: dates.length,
insertedCount
};
} catch (err: any) {
console.error('Error generating dummy data:', err);
return fail(500, { error: err.message || 'Erro interno ao gerar dados.' });
}
}
};
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
let isLoading = $state(false);
</script>
<svelte:head>
<title>Dummy Data Generator - RefoodOne</title>
</svelte:head>
<div class="container py-4">
<div class="mb-4">
<h2 class="fw-bold text-dark mb-1">Dummy Data Generator</h2>
<p class="text-muted mb-0">Ferramenta para redefinir e carregar dados fictícios de entregas</p>
</div>
{#if form?.error}
<div class="alert alert-danger border-0 shadow-sm rounded-3 mb-4 d-flex align-items-center gap-2" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-exclamation-triangle-fill text-danger" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
<div>
{form.error}
</div>
</div>
{/if}
{#if form?.success}
<div class="alert alert-success border-0 shadow-sm rounded-3 mb-4 d-flex align-items-center gap-2" 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>
Dados gerados com sucesso! Foram limpos todos os registos e inseridas <strong>{form.insertedCount} entregas</strong> ao longo de <strong>{form.totalDays} dias de entrega</strong>.
</div>
</div>
{/if}
<div class="card border-0 shadow-sm rounded-4 p-4 bg-white mb-4" style="max-width: 700px;">
<h5 class="fw-bold text-dark mb-3">Como funciona a geração de dados?</h5>
<div class="alert alert-warning border-0 rounded-3 mb-4" role="alert">
<div class="d-flex gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-exclamation-triangle-fill flex-shrink-0" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
<div>
<strong>Atenção:</strong> Esta ação irá apagar permanentemente todas as entregas atuais registadas na base de dados antes de carregar o conjunto de teste.
</div>
</div>
</div>
<ul class="list-group list-group-flush mb-4">
<li class="list-group-item border-0 px-0 py-2.5 d-flex align-items-start gap-2">
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-circle p-1"></span>
<div>
<strong>Limpeza Completa:</strong> Apaga todos os registos da tabela <code>deliveries</code>.
</div>
</li>
<li class="list-group-item border-0 px-0 py-2.5 d-flex align-items-start gap-2">
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-circle p-1"></span>
<div>
<strong>Garantia de Turno:</strong> Cria um turno padrão (T1) às Terças e Quintas caso nenhum esteja configurado.
</div>
</li>
<li class="list-group-item border-0 px-0 py-2.5 d-flex align-items-start gap-2">
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-circle p-1"></span>
<div>
<strong>Garantia de Beneficiários:</strong> Cria ou ativa os beneficiários <strong>#1, #2 e #3</strong> (configurados como principais/agregado e com data de criação superior a 35 dias).
</div>
</li>
<li class="list-group-item border-0 px-0 py-2.5 d-flex align-items-start gap-2">
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-circle p-1"></span>
<div>
<strong>Distribuição de Ausências nos Últimos 30 Dias:</strong>
<ul class="mt-1 mb-0 ps-3 small text-secondary">
<li>O beneficiário <strong>#1</strong> faltará exatamente a <strong>1 entrega</strong>.</li>
<li>O beneficiário <strong>#2</strong> faltará exatamente a <strong>2 entregas</strong>.</li>
<li>O beneficiário <strong>#3</strong> faltará exatamente a <strong>3 entregas</strong>.</li>
<li>Os restantes beneficiários ativos receberão em todas as datas (0 faltas).</li>
</ul>
</div>
</li>
</ul>
<form
method="POST"
action="?/generate"
use:enhance={() => {
isLoading = true;
return async ({ update }) => {
isLoading = false;
await update();
};
}}
onsubmit={(e) => {
if (!confirm('Tem a certeza que deseja APAGAR todas as entregas e gerar os dados de teste?')) {
e.preventDefault();
}
}}
>
<div class="d-flex align-items-center gap-3">
<button
type="submit"
class="btn btn-danger rounded-3 px-4 py-2.5 fw-bold"
disabled={isLoading}
>
{#if isLoading}
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
A Gerar Dados...
{:else}
Generate Data
{/if}
</button>
{#if form?.success}
<a href="/admin/ausencias" class="btn btn-outline-success rounded-3 px-4 py-2.5 fw-semibold">
Ver Relatório de Ausências →
</a>
{/if}
</div>
</form>
</div>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+2 -2
View File
@@ -1,3 +1,3 @@
# allow crawling everything by default
# Block all crawling
User-agent: *
Disallow:
Disallow: /
+28 -1
View File
@@ -1,6 +1,33 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
export default defineConfig({
plugins: [sveltekit()]
plugins: [
sveltekit(),
SvelteKitPWA({
registerType: 'autoUpdate',
manifest: {
name: 'Refood One',
short_name: 'RefoodOne',
description: 'Gestão de Beneficiários e Entregas Refood',
theme_color: '#FCB515',
background_color: '#ffffff',
display: 'standalone',
start_url: '/',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
}
})
]
});