Compare commits
6 Commits
75bc71eb39
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1133775c85 | |||
| 1d88b4cb1a | |||
| 521987f824 | |||
| 8f51987342 | |||
| 6ddc8974e0 | |||
| b1cc6368a1 |
@@ -1,2 +1,9 @@
|
|||||||
# Drizzle
|
# Drizzle
|
||||||
DATABASE_URL=local.db
|
DATABASE_URL=local.db
|
||||||
|
|
||||||
|
# Odoo API
|
||||||
|
ODOO_URL=
|
||||||
|
ODOO_DB=
|
||||||
|
ODOO_KEY=
|
||||||
|
ODOO_USER=
|
||||||
|
ODOO_COMPANY_ID=
|
||||||
|
|||||||
+274
@@ -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`.*
|
||||||
|
|
||||||
@@ -26,6 +26,5 @@ Main modules
|
|||||||
* Track delivery
|
* Track delivery
|
||||||
* List deliveries
|
* List deliveries
|
||||||
|
|
||||||
|
## Integrações Externas
|
||||||
|
* **Beneficiários**: O repositório dos beneficiários é a API json-rpc da Odoo v14.
|
||||||
|
|
||||||
|
|||||||
@@ -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`);
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1780444363958,
|
||||||
|
"tag": "0000_light_red_wolf",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+4663
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -13,7 +13,8 @@
|
|||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:seed": "node scripts/generate-dummy.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^24",
|
"@types/node": "^24",
|
||||||
|
"@vite-pwa/sveltekit": "^1.1.0",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"svelte": "^5.55.2",
|
"svelte": "^5.55.2",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -4,6 +4,11 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
const path = event.url.pathname;
|
const path = event.url.pathname;
|
||||||
|
|
||||||
// Route protection
|
// Route protection
|
||||||
if (path.startsWith('/admin')) {
|
if (path.startsWith('/admin') || path.startsWith('/super')) {
|
||||||
if (!event.locals.user) {
|
if (!event.locals.user) {
|
||||||
throw redirect(303, `/login?redirectTo=${encodeURIComponent(path)}`);
|
throw redirect(303, `/login?redirectTo=${encodeURIComponent(path)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,50 @@ try {
|
|||||||
console.error("Migration failed:", err);
|
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 });
|
export const db = drizzle(client, { schema });
|
||||||
|
|
||||||
// Seed admin user if the users table is empty (US00)
|
// Seed admin user if the users table is empty (US00)
|
||||||
|
|||||||
@@ -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', {
|
export const users = sqliteTable('users', {
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
@@ -19,15 +19,24 @@ export const sessions = sqliteTable('sessions', {
|
|||||||
|
|
||||||
export const beneficiaries = sqliteTable('beneficiaries', {
|
export const beneficiaries = sqliteTable('beneficiaries', {
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
number: integer('number').notNull().unique(),
|
number: integer('number'),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
contact: text('contact').notNull(),
|
contact: text('contact').notNull(),
|
||||||
householdSize: integer('household_size').notNull().default(1),
|
householdSize: integer('household_size').notNull().default(1),
|
||||||
observations: text('observations'),
|
observations: text('observations'),
|
||||||
status: text('status').notNull().default('ativo'), // 'ativo' | 'inativo'
|
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()),
|
createdAt: integer('created_at').notNull().$defaultFn(() => Date.now()),
|
||||||
updatedAt: integer('updated_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', {
|
export const shifts = sqliteTable('shifts', {
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
@@ -51,3 +60,10 @@ export const deliveries = sqliteTable('deliveries', {
|
|||||||
createdAt: integer('created_at').notNull().$defaultFn(() => Date.now())
|
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())
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { LayoutServerLoad } from './$types';
|
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 {
|
return {
|
||||||
user: locals.user
|
user: locals.user,
|
||||||
|
isLocalhost
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
|
|
||||||
@@ -9,6 +10,14 @@
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import('bootstrap/dist/js/bootstrap.bundle.min.js');
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -42,6 +51,9 @@
|
|||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item py-2 px-3 rounded-2" href="/admin/beneficiarios">Beneficiários</a>
|
<a class="dropdown-item py-2 px-3 rounded-2" href="/admin/beneficiarios">Beneficiários</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item py-2 px-3 rounded-2" href="/admin/ausencias">Ausências</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item py-2 px-3 rounded-2" href="/admin/turnos">Turnos</a>
|
<a class="dropdown-item py-2 px-3 rounded-2" href="/admin/turnos">Turnos</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -54,6 +66,27 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link px-3 rounded-2" href="/entregas">Entregas</a>
|
<a class="nav-link px-3 rounded-2" href="/entregas">Entregas</a>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
|||||||
@@ -37,6 +37,17 @@
|
|||||||
<span class="card-label fw-bold text-dark fs-4">Beneficiários</span>
|
<span class="card-label fw-bold text-dark fs-4">Beneficiários</span>
|
||||||
</a>
|
</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) -->
|
<!-- 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">
|
<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">
|
<div class="icon-wrapper d-flex align-items-center justify-content-center mb-3 text-white rounded-circle shadow-sm">
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -8,7 +8,10 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||||||
const status = url.searchParams.get('status') || '';
|
const status = url.searchParams.get('status') || '';
|
||||||
|
|
||||||
try {
|
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).
|
// 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.
|
// This keeps SQLite query logic extremely simple and readable.
|
||||||
@@ -19,7 +22,7 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||||||
list = list.filter(
|
list = list.filter(
|
||||||
(b) =>
|
(b) =>
|
||||||
b.name.toLowerCase().includes(searchLower) ||
|
b.name.toLowerCase().includes(searchLower) ||
|
||||||
b.number.toString().includes(searchLower) ||
|
(b.number !== null && b.number.toString().includes(searchLower)) ||
|
||||||
(b.contact && b.contact.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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,46 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
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'));
|
const successMessage = $derived(page.url.searchParams.get('success'));
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -24,11 +59,51 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="fw-bold text-dark mb-1">Beneficiários</h2>
|
<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>
|
<p class="text-muted mb-0">Gerir a listagem de beneficiários e agregados familiares</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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);">
|
<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">
|
<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"/>
|
<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"/>
|
||||||
@@ -36,6 +111,7 @@
|
|||||||
Novo Beneficiário
|
Novo Beneficiário
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm rounded-4 p-3 mb-4 bg-white">
|
<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">
|
<form method="GET" class="row g-3 align-items-end">
|
||||||
@@ -69,20 +145,44 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm rounded-4 overflow-hidden bg-white">
|
<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">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead class="table-light text-secondary fw-semibold">
|
<thead class="table-light text-secondary fw-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3" style="width: 120px;">Número</th>
|
<th class="px-4 py-3 sortable-header" style="width: 120px;" onclick={() => toggleSort('number')}>
|
||||||
<th class="py-3">Nome</th>
|
<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">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="py-3" style="width: 120px;">Estado</th>
|
||||||
<th class="px-4 py-3 text-end" style="width: 120px;">Ações</th>
|
<th class="px-4 py-3 text-end" style="width: 120px;">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#if data.beneficiaries.length === 0}
|
{#if sortedBeneficiaries.length === 0}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="text-center py-5 text-muted">
|
<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">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
{#each data.beneficiaries as beneficiary}
|
{#each sortedBeneficiaries as beneficiary}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-4 fw-bold text-secondary">
|
<td class="px-4 fw-bold text-secondary">
|
||||||
#{beneficiary.number}
|
#{beneficiary.number}
|
||||||
@@ -129,3 +229,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 {
|
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
|
const existingConflict = db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.beneficiaries)
|
.from(schema.beneficiaries)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.beneficiaries.number, number),
|
eq(schema.beneficiaries.number, number),
|
||||||
ne(schema.beneficiaries.id, id)
|
ne(schema.beneficiaries.id, id),
|
||||||
|
eq(schema.beneficiaries.isParent, true)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.get();
|
.get();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import * as schema from '$lib/server/db/schema';
|
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 { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
|
|
||||||
@@ -39,11 +39,16 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if number is unique
|
// Check if number is unique among parent/main beneficiaries
|
||||||
const existing = db
|
const existing = db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.beneficiaries)
|
.from(schema.beneficiaries)
|
||||||
.where(eq(schema.beneficiaries.number, number))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.beneficiaries.number, number),
|
||||||
|
eq(schema.beneficiaries.isParent, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import * as schema from '$lib/server/db/schema';
|
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 { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||||
if (!locals.user) {
|
if (!locals.user) {
|
||||||
throw redirect(303, '/login');
|
throw redirect(303, '/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const todayStr = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' }); // 'YYYY-MM-DD'
|
const todayStr = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' }); // 'YYYY-MM-DD'
|
||||||
|
const selectedDate = url.searchParams.get('date') || todayStr;
|
||||||
|
|
||||||
try {
|
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
|
const activeBeneficiaries = db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.beneficiaries)
|
.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))
|
.orderBy(asc(schema.beneficiaries.number))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
@@ -27,7 +33,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
.orderBy(asc(schema.shifts.startTime))
|
.orderBy(asc(schema.shifts.startTime))
|
||||||
.all();
|
.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
|
const todayDeliveries = db
|
||||||
.select({
|
.select({
|
||||||
id: schema.deliveries.id,
|
id: schema.deliveries.id,
|
||||||
@@ -39,17 +45,19 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
.from(schema.deliveries)
|
.from(schema.deliveries)
|
||||||
.innerJoin(schema.beneficiaries, eq(schema.deliveries.beneficiaryId, schema.beneficiaries.id))
|
.innerJoin(schema.beneficiaries, eq(schema.deliveries.beneficiaryId, schema.beneficiaries.id))
|
||||||
.innerJoin(schema.shifts, eq(schema.deliveries.shiftId, schema.shifts.id))
|
.innerJoin(schema.shifts, eq(schema.deliveries.shiftId, schema.shifts.id))
|
||||||
.where(eq(schema.deliveries.date, todayStr))
|
.where(eq(schema.deliveries.date, selectedDate))
|
||||||
.all();
|
.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);
|
const deliveredIds = todayDeliveries.map((d) => d.beneficiary.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
beneficiaries: activeBeneficiaries,
|
beneficiaries: activeBeneficiaries,
|
||||||
shifts: shiftsList,
|
shifts: shiftsList,
|
||||||
todayDeliveries,
|
todayDeliveries,
|
||||||
deliveredIds
|
deliveredIds,
|
||||||
|
selectedDate,
|
||||||
|
todayStr
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading deliveries page:', err);
|
console.error('Error loading deliveries page:', err);
|
||||||
@@ -58,6 +66,8 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
shifts: [],
|
shifts: [],
|
||||||
todayDeliveries: [],
|
todayDeliveries: [],
|
||||||
deliveredIds: [],
|
deliveredIds: [],
|
||||||
|
selectedDate,
|
||||||
|
todayStr,
|
||||||
error: 'Erro ao carregar os dados de entregas.'
|
error: 'Erro ao carregar os dados de entregas.'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -179,6 +189,22 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// Delete the delivery record
|
||||||
db.delete(schema.deliveries)
|
db.delete(schema.deliveries)
|
||||||
.where(eq(schema.deliveries.id, deliveryId))
|
.where(eq(schema.deliveries.id, deliveryId))
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
let activeModalBeneficiary = $state<any>(null);
|
let activeModalBeneficiary = $state<any>(null);
|
||||||
let activeDeleteDelivery = $state<any>(null);
|
let activeDeleteDelivery = $state<any>(null);
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
let dateInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Derived list of delivered IDs so UI updates dynamically
|
// Derived list of delivered IDs so UI updates dynamically
|
||||||
const deliveredSet = $derived(new Set(data.deliveredIds || []));
|
const deliveredSet = $derived(new Set(data.deliveredIds || []));
|
||||||
|
const isToday = $derived(data.selectedDate === data.todayStr);
|
||||||
|
|
||||||
function openConfirmation(beneficiary: any) {
|
function openConfirmation(beneficiary: any) {
|
||||||
if (deliveredSet.has(beneficiary.id)) return;
|
if (deliveredSet.has(beneficiary.id) || !isToday) return;
|
||||||
activeModalBeneficiary = beneficiary;
|
activeModalBeneficiary = beneficiary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +41,17 @@
|
|||||||
return '';
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<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 class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="fw-bold text-dark mb-1">Registo de Entregas</h2>
|
<h2 class="fw-bold text-dark mb-1">Registo de Entregas</h2>
|
||||||
|
{#if isToday}
|
||||||
<p class="text-muted mb-0">Selecione o número do beneficiário para registar a entrega do cabaz de hoje</p>
|
<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>
|
||||||
|
<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">
|
<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">
|
<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="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"/>
|
<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>
|
</svg>
|
||||||
<span class="fw-semibold">{new Date().toLocaleDateString('pt-PT', { timeZone: 'Europe/Lisbon', weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,7 +99,17 @@
|
|||||||
|
|
||||||
<!-- Beneficiaries Grid -->
|
<!-- Beneficiaries Grid -->
|
||||||
<div class="card border-0 shadow-sm rounded-4 p-4 mb-4 bg-white">
|
<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}
|
{#if data.beneficiaries.length === 0}
|
||||||
<div class="text-center py-5 text-muted">
|
<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">
|
<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)}
|
{@const isDelivered = deliveredSet.has(beneficiary.id)}
|
||||||
<button
|
<button
|
||||||
type="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'}"
|
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={() => openConfirmation(beneficiary)}
|
onclick={() => isToday && openConfirmation(beneficiary)}
|
||||||
disabled={isDelivered}
|
disabled={isDelivered || !isToday}
|
||||||
aria-label="Beneficiário número {beneficiary.number} - {beneficiary.name}"
|
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;">
|
<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]}
|
{beneficiary.name.split(' ')[0]}
|
||||||
</span>
|
</span>
|
||||||
{#if isDelivered}
|
{#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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
@@ -107,7 +148,7 @@
|
|||||||
<!-- Today's Recent Deliveries -->
|
<!-- Today's Recent Deliveries -->
|
||||||
<div class="card border-0 shadow-sm rounded-4 overflow-hidden bg-white">
|
<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">
|
<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>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<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">Nome do Beneficiário</th>
|
||||||
<th class="py-3">Turno</th>
|
<th class="py-3">Turno</th>
|
||||||
<th class="py-3" style="width: 180px;">Hora do Registo</th>
|
<th class="py-3" style="width: 180px;">Hora do Registo</th>
|
||||||
|
{#if isToday}
|
||||||
<th class="py-3 px-4 text-end" style="width: 100px;">Ações</th>
|
<th class="py-3 px-4 text-end" style="width: 100px;">Ações</th>
|
||||||
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#if data.todayDeliveries.length === 0}
|
{#if data.todayDeliveries.length === 0}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="text-center py-4 text-muted">
|
<td colspan={isToday ? 5 : 4} class="text-center py-4 text-muted">
|
||||||
Nenhum cabaz entregue hoje.
|
Nenhum cabaz entregue nesta data.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -144,6 +187,7 @@
|
|||||||
<td class="text-secondary fw-medium">
|
<td class="text-secondary fw-medium">
|
||||||
{formatTime(delivery.createdAt)}
|
{formatTime(delivery.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
|
{#if isToday}
|
||||||
<td class="px-4 text-end">
|
<td class="px-4 text-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -157,6 +201,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -322,6 +367,13 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-grid-beneficiary.disabled-past {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-color: #dee2e6;
|
||||||
|
color: #adb5bd;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.beneficiary-number {
|
.beneficiary-number {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -330,4 +382,16 @@
|
|||||||
.transition {
|
.transition {
|
||||||
transition: all 0.15s ease-in-out;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
// If already logged in, redirect them
|
// If already logged in, redirect them
|
||||||
@@ -67,7 +68,7 @@ export const actions: Actions = {
|
|||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
secure: true,
|
secure: !dev,
|
||||||
maxAge: 60 * 60 * 24 * 7 // 7 days in seconds
|
maxAge: 60 * 60 * 24 * 7 // 7 days in seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
@@ -1,3 +1,3 @@
|
|||||||
# allow crawling everything by default
|
# Block all crawling
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow:
|
Disallow: /
|
||||||
|
|||||||
+28
-1
@@ -1,6 +1,33 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||||
|
|
||||||
export default defineConfig({
|
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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user