Compare commits
4 Commits
6ddc8974e0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1133775c85 | |||
| 1d88b4cb1a | |||
| 521987f824 | |||
| 8f51987342 |
+3
-3
@@ -267,8 +267,8 @@ sudo -u refood DATABASE_URL=local.db npm run db:push
|
|||||||
# 5. Compilar o projeto novamente
|
# 5. Compilar o projeto novamente
|
||||||
sudo -u refood npm run build
|
sudo -u refood npm run build
|
||||||
|
|
||||||
# 6. Recarregar o PM2 de forma segura (Zero Downtime)
|
# 6. Recarregar o PM2 de forma segura (Zero Downtime) e atualizar variáveis de ambiente
|
||||||
sudo -u refood pm2 reload refood-one
|
sudo -u refood pm2 reload refood-one --update-env
|
||||||
```
|
```
|
||||||
*Dica: O comando `pm2 reload` reinicia os processos de forma faseada, garantindo que o seu site não fica fora do ar (zero downtime) enquanto a atualização é aplicada.*
|
*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`.*
|
||||||
|
|
||||||
|
|||||||
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)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export async function syncBeneficiaries(): Promise<{ success: boolean; count: nu
|
|||||||
const parentOdooId = Array.isArray(result.parent_id) ? result.parent_id[0] : null;
|
const parentOdooId = Array.isArray(result.parent_id) ? result.parent_id[0] : null;
|
||||||
const isParent = parentOdooId === null;
|
const isParent = parentOdooId === null;
|
||||||
const activeStatus = result.active !== false ? 'ativo' : 'inativo';
|
const activeStatus = result.active !== false ? 'ativo' : 'inativo';
|
||||||
|
const beneficiaryNumber = isParent ? parsedNumber : null;
|
||||||
|
|
||||||
// Check if beneficiary with odooId already exists
|
// Check if beneficiary with odooId already exists
|
||||||
const existingById = db
|
const existingById = db
|
||||||
@@ -162,7 +163,7 @@ export async function syncBeneficiaries(): Promise<{ success: boolean; count: nu
|
|||||||
.set({
|
.set({
|
||||||
name,
|
name,
|
||||||
contact,
|
contact,
|
||||||
number: parsedNumber !== null ? parsedNumber : existingById.number,
|
number: beneficiaryNumber,
|
||||||
odooNumber,
|
odooNumber,
|
||||||
parentOdooId,
|
parentOdooId,
|
||||||
isParent,
|
isParent,
|
||||||
@@ -175,7 +176,7 @@ export async function syncBeneficiaries(): Promise<{ success: boolean; count: nu
|
|||||||
// Insert new
|
// Insert new
|
||||||
db.insert(schema.beneficiaries)
|
db.insert(schema.beneficiaries)
|
||||||
.values({
|
.values({
|
||||||
number: parsedNumber,
|
number: beneficiaryNumber,
|
||||||
name,
|
name,
|
||||||
contact,
|
contact,
|
||||||
odooId,
|
odooId,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ 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, who are main beneficiaries (no parentOdooId)
|
// Fetch active beneficiaries sorted by their unique number, who are main beneficiaries (no parentOdooId)
|
||||||
@@ -32,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,
|
||||||
@@ -44,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);
|
||||||
@@ -63,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.'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -184,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>
|
||||||
<p class="text-muted mb-0">Selecione o número do beneficiário para registar a entrega do cabaz de hoje</p>
|
{#if isToday}
|
||||||
|
<p class="text-muted mb-0">Selecione o número do beneficiário para registar a entrega do cabaz de hoje</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted mb-0">Visualização de registos de entrega para a data selecionada (alterações desabilitadas)</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-dark text-white px-3 py-2 rounded-3 shadow-sm d-flex align-items-center gap-2">
|
<div class="position-relative d-inline-block">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar-event" viewBox="0 0 16 16">
|
<!-- original style and layout wrapper -->
|
||||||
<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"/>
|
<div class="bg-dark text-white px-3 py-2 rounded-3 shadow-sm d-flex align-items-center gap-2">
|
||||||
<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 xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar-event" viewBox="0 0 16 16">
|
||||||
</svg>
|
<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"/>
|
||||||
<span class="fw-semibold">{new Date().toLocaleDateString('pt-PT', { timeZone: 'Europe/Lisbon', weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</span>
|
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="fw-semibold text-capitalize">{formatDateLong(data.selectedDate)}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Overlay input overlayed on top -->
|
||||||
|
<form method="GET" class="position-absolute top-0 start-0 w-100 h-100 m-0 opacity-0" style="z-index: 5;">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="date"
|
||||||
|
value={data.selectedDate}
|
||||||
|
onchange={(e) => e.currentTarget.form?.submit()}
|
||||||
|
class="datepicker-overlay w-100 h-100 border-0 cursor-pointer position-absolute top-0 start-0"
|
||||||
|
style="cursor: pointer; z-index: 5;"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,9 +123,9 @@
|
|||||||
{@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>
|
||||||
@@ -117,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">
|
||||||
@@ -127,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>
|
||||||
<th class="py-3 px-4 text-end" style="width: 100px;">Ações</th>
|
{#if isToday}
|
||||||
|
<th class="py-3 px-4 text-end" style="width: 100px;">Ações</th>
|
||||||
|
{/if}
|
||||||
</tr>
|
</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}
|
||||||
@@ -154,19 +187,21 @@
|
|||||||
<td class="text-secondary fw-medium">
|
<td class="text-secondary fw-medium">
|
||||||
{formatTime(delivery.createdAt)}
|
{formatTime(delivery.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 text-end">
|
{#if isToday}
|
||||||
<button
|
<td class="px-4 text-end">
|
||||||
type="button"
|
<button
|
||||||
class="btn btn-sm btn-link text-danger p-0 border-0 align-middle transition"
|
type="button"
|
||||||
title="Apagar Registo"
|
class="btn btn-sm btn-link text-danger p-0 border-0 align-middle transition"
|
||||||
onclick={() => openDeleteConfirmation(delivery)}
|
title="Apagar Registo"
|
||||||
disabled={isLoading}
|
onclick={() => openDeleteConfirmation(delivery)}
|
||||||
>
|
disabled={isLoading}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-trash3" viewBox="0 0 16 16">
|
>
|
||||||
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5"/>
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-trash3" viewBox="0 0 16 16">
|
||||||
</svg>
|
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5"/>
|
||||||
</button>
|
</svg>
|
||||||
</td>
|
</button>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -332,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;
|
||||||
@@ -340,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>
|
||||||
|
|||||||
@@ -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 |
+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