Ecrão das ausências

This commit is contained in:
Duarte
2026-06-04 18:51:22 +01:00
parent 6ddc8974e0
commit 8f51987342
10 changed files with 807 additions and 36 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ export const handle: Handle = async ({ event, resolve }) => {
const path = event.url.pathname;
// Route protection
if (path.startsWith('/admin')) {
if (path.startsWith('/admin') || path.startsWith('/super')) {
if (!event.locals.user) {
throw redirect(303, `/login?redirectTo=${encodeURIComponent(path)}`);
}
+6 -2
View File
@@ -1,7 +1,11 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
export const load: LayoutServerLoad = async ({ locals, url }) => {
const hostname = url.hostname;
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
return {
user: locals.user
user: locals.user,
isLocalhost
};
};
+24
View File
@@ -42,6 +42,9 @@
<li>
<a class="dropdown-item py-2 px-3 rounded-2" href="/admin/beneficiarios">Beneficiários</a>
</li>
<li>
<a class="dropdown-item py-2 px-3 rounded-2" href="/admin/ausencias">Ausências</a>
</li>
<li>
<a class="dropdown-item py-2 px-3 rounded-2" href="/admin/turnos">Turnos</a>
</li>
@@ -54,6 +57,27 @@
<li class="nav-item">
<a class="nav-link px-3 rounded-2" href="/entregas">Entregas</a>
</li>
{#if data.user.role === 'admin' && data.isLocalhost}
<li class="nav-item dropdown">
<button type="button" class="nav-link dropdown-toggle px-3 rounded-2 bg-transparent border-0 text-warning fw-bold" id="superDropdown" data-bs-toggle="dropdown" aria-expanded="false">
Super
</button>
<ul class="dropdown-menu border-0 shadow mt-2" aria-labelledby="superDropdown">
<li>
<span class="dropdown-item-text text-muted small py-2 px-3 fw-semibold">Ferramentas Super</span>
</li>
<li>
<a class="dropdown-item py-2 px-3 rounded-2" href="/super/dummy-data">Dummy Data</a>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<span class="dropdown-item py-2 px-3 text-secondary small">Acesso Localhost Ativo</span>
</li>
</ul>
</li>
{/if}
</ul>
<div class="d-flex align-items-center gap-3">
+11
View File
@@ -37,6 +37,17 @@
<span class="card-label fw-bold text-dark fs-4">Beneficiários</span>
</a>
<!-- Ausências Card (Admin only) -->
<a href="/admin/ausencias" class="dashboard-card d-flex flex-column align-items-center justify-content-center text-decoration-none shadow p-4 rounded-4 transition">
<div class="icon-wrapper d-flex align-items-center justify-content-center mb-3 text-white rounded-circle shadow-sm">
<!-- Calendar X / Absences Icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-calendar-x-fill" viewBox="0 0 16 16">
<path d="M4 .5a.5.5 0 0 0-1 0V1H2a2 2 0 0 0-2 2v1h16V3a2 2 0 0 0-2-2h-1V.5a.5.5 0 0 0-1 0V1H4zm12 5H0v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2zM6.854 8.146 8 9.293l1.146-1.147a.5.5 0 1 1 .708.708L8.707 10l1.147 1.146a.5.5 0 0 1-.708.708L8 10.707l-1.146 1.147a.5.5 0 0 1-.708-.708L7.293 10 6.146 8.854a.5.5 0 1 1 .708-.708"/>
</svg>
</div>
<span class="card-label fw-bold text-dark fs-4">Ausências</span>
</a>
<!-- Utilizadores Card (Admin only) -->
<a href="/admin/utilizadores" class="dashboard-card d-flex flex-column align-items-center justify-content-center text-decoration-none shadow p-4 rounded-4 transition">
<div class="icon-wrapper d-flex align-items-center justify-content-center mb-3 text-white rounded-circle shadow-sm">
+144
View File
@@ -0,0 +1,144 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq, and, gte, lte, sql } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const search = url.searchParams.get('search') || '';
const minAbsencesStr = url.searchParams.get('minAbsences') || '1';
const minAbsences = parseInt(minAbsencesStr, 10) || 1;
// Define timezone-adjusted default date boundaries
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(today.getDate() - 30);
const defaultStartDate = thirtyDaysAgo.toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
const defaultEndDate = yesterday.toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
const startDate = url.searchParams.get('startDate') || defaultStartDate;
const endDate = url.searchParams.get('endDate') || defaultEndDate;
try {
// 1. Get all active parent beneficiaries
const activeBeneficiaries = db
.select()
.from(schema.beneficiaries)
.where(
and(
eq(schema.beneficiaries.status, 'ativo'),
eq(schema.beneficiaries.isParent, true)
)
)
.all();
// 3. Find all unique delivery dates where at least one delivery occurred in the selected range
const deliveryDatesResult = db
.select({
date: schema.deliveries.date
})
.from(schema.deliveries)
.where(
and(
gte(schema.deliveries.date, startDate),
lte(schema.deliveries.date, endDate)
)
)
.all();
const deliveryDates = Array.from(new Set(deliveryDatesResult.map((r) => r.date))).sort();
// 4. Fetch all deliveries in the selected range
const recentDeliveries = db
.select()
.from(schema.deliveries)
.where(
and(
gte(schema.deliveries.date, startDate),
lte(schema.deliveries.date, endDate)
)
)
.all();
// 5. Fetch the last delivery date of all time for each beneficiary
const lastDeliveries = db
.select({
beneficiaryId: schema.deliveries.beneficiaryId,
lastDate: sql<string>`max(${schema.deliveries.date})`
})
.from(schema.deliveries)
.groupBy(schema.deliveries.beneficiaryId)
.all();
const lastDeliveryMap = new Map(lastDeliveries.map((d) => [d.beneficiaryId, d.lastDate]));
// Helper to convert timestamp to date string
const timestampToDateStr = (timestamp: number): string => {
return new Date(timestamp).toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
};
// 6. Map and process absences per beneficiary
let list = activeBeneficiaries.map((b) => {
const registeredDateStr = timestampToDateStr(b.createdAt);
// Only delivery dates on/after registration date are relevant
const relevantDeliveryDates = deliveryDates.filter((date) => date >= registeredDateStr);
// Actual deliveries received by this beneficiary
const receivedDates = recentDeliveries
.filter((d) => d.beneficiaryId === b.id)
.map((d) => d.date);
// Missed delivery dates
const missedDates = relevantDeliveryDates.filter((date) => !receivedDates.includes(date));
return {
...b,
totalRelevantDeliveries: relevantDeliveryDates.length,
receivedCount: receivedDates.length,
absencesCount: missedDates.length,
missedDates: missedDates.reverse(), // Show latest missed dates first
lastDelivery: lastDeliveryMap.get(b.id) || null
};
});
// 7. Filter by minimum absences (default >= 1)
list = list.filter((b) => b.absencesCount >= minAbsences);
// 8. Apply search filters (name/number/contact)
if (search) {
const searchLower = search.toLowerCase();
list = list.filter(
(b) =>
b.name.toLowerCase().includes(searchLower) ||
(b.number !== null && b.number.toString().includes(searchLower)) ||
(b.contact && b.contact.includes(searchLower))
);
}
// Sort by absences count descending, then by number ascending
list.sort((a, b) => b.absencesCount - a.absencesCount || (a.number || 0) - (b.number || 0));
return {
beneficiaries: list,
totalDeliveryDays: deliveryDates.length,
search,
minAbsences,
startDate,
endDate
};
} catch (err) {
console.error('Error loading absences data:', err);
return {
beneficiaries: [],
totalDeliveryDays: 0,
search,
minAbsences,
startDate,
endDate,
error: 'Erro ao processar o histórico de ausências dos beneficiários.'
};
}
};
+237
View File
@@ -0,0 +1,237 @@
<script lang="ts">
let { data } = $props();
function formatDate(dateStr: string): string {
if (!dateStr) return '';
try {
const [year, month, day] = dateStr.split('-');
const date = new Date(Number(year), Number(month) - 1, Number(day));
const formatted = date.toLocaleDateString('pt-PT', { weekday: 'short', day: 'numeric', month: 'short' });
// Capitalize weekday
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
} catch {
return dateStr;
}
}
function calculateAssiduity(received: number, total: number): number {
if (total === 0) return 100;
return Math.round((received / total) * 100);
}
</script>
<svelte:head>
<title>Ausências de Beneficiários - RefoodOne</title>
</svelte:head>
<div class="container py-4">
<div class="mb-4">
<h2 class="fw-bold text-dark mb-1">Ausências de Beneficiários</h2>
<p class="text-muted mb-0">Controlo de faltas e assiduidade no período selecionado (entregas 2 vezes por semana)</p>
</div>
{#if data.error}
<div class="alert alert-danger border-0 shadow-sm rounded-3 mb-4" role="alert">
{data.error}
</div>
{/if}
<!-- Summary Statistics Cards -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-0 shadow-sm rounded-4 p-3 bg-white h-100">
<div class="d-flex align-items-center gap-3">
<div class="stat-icon bg-success-subtle text-success rounded-3 p-3 d-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-calendar-check" viewBox="0 0 16 16">
<path d="M10.854 7.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 9.793l2.646-2.647a.5.5 0 0 1 .708 0"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
</svg>
</div>
<div>
<h6 class="text-secondary fw-semibold small mb-1">Dias de Entrega Ocorridos</h6>
<h3 class="fw-bold text-dark mb-0">{data.totalDeliveryDays} dias</h3>
<span class="text-muted small">no período selecionado</span>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm rounded-4 p-3 bg-white h-100">
<div class="d-flex align-items-center gap-3">
<div class="stat-icon bg-danger-subtle text-danger rounded-3 p-3 d-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-person-exclamation" viewBox="0 0 16 16">
<path d="M11 5a3 3 0 1 1-6 0 3 3 0 0 1 6 0M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4m.002 6a4.99 4.99 0 0 1 5.021-1.268 4 4 0 0 0-3.094-1.148H6a4 4 0 0 0-4 4v.5c0 .502.405.82 1 .82h5.216a5 5 0 0 1-.214-1.025L3.008 13zm9.998-1.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0m-.5-3.875a.41.41 0 0 0-.406.416v1.854a.41.41 0 0 0 .812 0V8.041a.41.41 0 0 0-.406-.416"/>
</svg>
</div>
<div>
<h6 class="text-secondary fw-semibold small mb-1">Beneficiários com Faltas</h6>
<h3 class="fw-bold text-dark mb-0">{data.beneficiaries.length}</h3>
<span class="text-muted small">ativos e com faltas registadas</span>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm rounded-4 p-3 bg-white h-100">
<div class="d-flex align-items-center gap-3">
<div class="stat-icon bg-warning-subtle text-warning-emphasis rounded-3 p-3 d-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-percent" viewBox="0 0 16 16">
<path d="M13.485 1.431a.5.5 0 0 1 .37.662l-9.2 13.5a.5.5 0 1 1-.84-.572l9.2-13.5a.5.5 0 0 1 .67-.19zM4.15 2.5a2 2 0 1 1-2.757 2.915A2 2 0 0 1 4.15 2.5zm0 1a1 1 0 1 0-1.378 1.458A1 1 0 0 0 4.15 3.5zm7.7 7.5a2 2 0 1 1-2.757 2.915A2 2 0 0 1 11.85 11zm0 1a1 1 0 1 0-1.378 1.458A1 1 0 0 0 11.85 12.5z"/>
</svg>
</div>
<div>
<h6 class="text-secondary fw-semibold small mb-1">Filtro de Relevância</h6>
<h3 class="fw-bold text-dark mb-0">{data.minAbsences} {data.minAbsences === 1 ? 'falta' : 'faltas'}</h3>
<span class="text-muted small">limiar selecionado para aviso</span>
</div>
</div>
</div>
</div>
</div>
<!-- Filters & Search -->
<div class="card border-0 shadow-sm rounded-4 p-3 mb-4 bg-white">
<form method="GET" class="row g-3 align-items-end">
<div class="col-md-6 col-lg-4">
<label for="search" class="form-label fw-semibold text-secondary small">Pesquisar</label>
<input
type="text"
name="search"
id="search"
value={data.search}
placeholder="Pesquise por nome, número ou contacto..."
class="form-control rounded-3 border-2"
/>
</div>
<div class="col-md-6 col-lg-2">
<label for="minAbsences" class="form-label fw-semibold text-secondary small">Mínimo de Faltas</label>
<select name="minAbsences" id="minAbsences" value={data.minAbsences.toString()} class="form-select rounded-3 border-2">
<option value="1">1 ou mais faltas</option>
<option value="2">2 ou mais faltas</option>
<option value="3">3 ou mais faltas</option>
<option value="4">4 ou mais faltas</option>
</select>
</div>
<div class="col-md-4 col-lg-2">
<label for="startDate" class="form-label fw-semibold text-secondary small">Data de Início</label>
<input
type="date"
name="startDate"
id="startDate"
value={data.startDate}
class="form-control rounded-3 border-2"
/>
</div>
<div class="col-md-4 col-lg-2">
<label for="endDate" class="form-label fw-semibold text-secondary small">Data de Fim</label>
<input
type="date"
name="endDate"
id="endDate"
value={data.endDate}
class="form-control rounded-3 border-2"
/>
</div>
<div class="col-md-4 col-lg-2 d-grid">
<button type="submit" class="btn btn-primary rounded-3 fw-semibold border-0 py-2" style="background-color: #2c562e;">
Filtrar
</button>
</div>
</form>
</div>
<!-- Main Absences Table -->
<div class="card border-0 shadow-sm rounded-4 overflow-hidden bg-white">
<div class="card-header border-0 bg-light py-3 px-4">
<h5 class="fw-bold text-dark mb-0">Relatório de Assiduidade</h5>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-secondary fw-semibold">
<tr>
<th class="px-4 py-3" style="width: 120px;">Número</th>
<th class="py-3" style="width: 220px;">Nome</th>
<th class="py-3" style="width: 150px;">Contacto</th>
<th class="py-3 text-center" style="width: 120px;">Faltas / Entregas</th>
<th class="py-3 text-center" style="width: 120px;">Assiduidade</th>
<th class="py-3">Dias com Ausência no Período</th>
<th class="py-3" style="width: 140px;">Última Entrega</th>
<th class="px-4 py-3 text-end" style="width: 120px;">Ações</th>
</tr>
</thead>
<tbody>
{#if data.beneficiaries.length === 0}
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="currentColor" class="bi bi-check2-circle mb-2 text-success" viewBox="0 0 16 16">
<path d="M2.5 8a5.5 5.5 0 0 1 8.25-4.764.5.5 0 0 0 .5-.866A6.5 6.5 0 1 0 14.5 8a.5.5 0 0 0-1 0 5.5 5.5 0 1 1-11 0"/>
<path d="M15.354 3.354a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0z"/>
</svg>
<p class="mb-0 fw-semibold">Nenhum beneficiário com ausências registadas.</p>
</td>
</tr>
{:else}
{#each data.beneficiaries as b}
{@const assid = calculateAssiduity(b.receivedCount, b.totalRelevantDeliveries)}
<tr>
<td class="px-4 fw-bold text-secondary">
#{b.number}
</td>
<td class="fw-semibold text-dark">
{b.name}
</td>
<td class="text-secondary small">
{b.contact || 'Sem contacto'}
</td>
<td class="text-center fw-semibold">
<span class="text-danger">{b.absencesCount}</span> / {b.totalRelevantDeliveries}
</td>
<td class="text-center">
<span class="badge rounded-pill fw-bold text-uppercase px-2.5 py-1.5 border {assid >= 75 ? 'bg-success-subtle text-success border-success-subtle' : assid >= 50 ? 'bg-warning-subtle text-warning border-warning-subtle' : 'bg-danger-subtle text-danger border-danger-subtle'}" style="font-size: 0.7rem;">
{assid}%
</span>
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{#each b.missedDates as date}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fw-semibold rounded-2 px-2 py-1" style="font-size: 0.75rem;">
{formatDate(date)}
</span>
{/each}
</div>
</td>
<td class="text-secondary small fw-medium">
{b.lastDelivery ? formatDate(b.lastDelivery) : 'Nunca'}
</td>
<td class="px-4 text-end">
<a href="/admin/beneficiarios/{b.id}" class="btn btn-sm btn-outline-secondary rounded-2 px-2.5 py-1 d-inline-flex align-items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"/>
</svg>
Ficha
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
<style>
.stat-icon {
width: 48px;
height: 48px;
}
.badge {
letter-spacing: 0.02em;
}
</style>
+26 -5
View File
@@ -4,12 +4,13 @@ import { eq, and, asc, isNull } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
export const load: PageServerLoad = async ({ url, locals }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
const todayStr = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' }); // 'YYYY-MM-DD'
const selectedDate = url.searchParams.get('date') || todayStr;
try {
// Fetch active beneficiaries sorted by their unique number, who are main beneficiaries (no parentOdooId)
@@ -32,7 +33,7 @@ export const load: PageServerLoad = async ({ locals }) => {
.orderBy(asc(schema.shifts.startTime))
.all();
// Fetch today's deliveries with associated beneficiary and shift details
// Fetch deliveries for the selected date with associated beneficiary and shift details
const todayDeliveries = db
.select({
id: schema.deliveries.id,
@@ -44,17 +45,19 @@ export const load: PageServerLoad = async ({ locals }) => {
.from(schema.deliveries)
.innerJoin(schema.beneficiaries, eq(schema.deliveries.beneficiaryId, schema.beneficiaries.id))
.innerJoin(schema.shifts, eq(schema.deliveries.shiftId, schema.shifts.id))
.where(eq(schema.deliveries.date, todayStr))
.where(eq(schema.deliveries.date, selectedDate))
.all();
// Compile the list of beneficiary IDs who already received a basket today
// Compile the list of beneficiary IDs who already received a basket
const deliveredIds = todayDeliveries.map((d) => d.beneficiary.id);
return {
beneficiaries: activeBeneficiaries,
shifts: shiftsList,
todayDeliveries,
deliveredIds
deliveredIds,
selectedDate,
todayStr
};
} catch (err) {
console.error('Error loading deliveries page:', err);
@@ -63,6 +66,8 @@ export const load: PageServerLoad = async ({ locals }) => {
shifts: [],
todayDeliveries: [],
deliveredIds: [],
selectedDate,
todayStr,
error: 'Erro ao carregar os dados de entregas.'
};
}
@@ -184,6 +189,22 @@ export const actions: Actions = {
}
try {
// Get the delivery to verify its date
const delivery = db
.select()
.from(schema.deliveries)
.where(eq(schema.deliveries.id, deliveryId))
.get();
if (!delivery) {
return fail(404, { error: 'Registo de entrega não encontrado.' });
}
const todayStr = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
if (delivery.date !== todayStr) {
return fail(400, { error: 'Não é permitido apagar registos de dias passados.' });
}
// Delete the delivery record
db.delete(schema.deliveries)
.where(eq(schema.deliveries.id, deliveryId))
+82 -28
View File
@@ -6,12 +6,14 @@
let activeModalBeneficiary = $state<any>(null);
let activeDeleteDelivery = $state<any>(null);
let isLoading = $state(false);
let dateInput = $state<HTMLInputElement | null>(null);
// Derived list of delivered IDs so UI updates dynamically
const deliveredSet = $derived(new Set(data.deliveredIds || []));
const isToday = $derived(data.selectedDate === data.todayStr);
function openConfirmation(beneficiary: any) {
if (deliveredSet.has(beneficiary.id)) return;
if (deliveredSet.has(beneficiary.id) || !isToday) return;
activeModalBeneficiary = beneficiary;
}
@@ -39,6 +41,17 @@
return '';
}
}
function formatDateLong(dateStr: string): string {
if (!dateStr) return '';
try {
const [year, month, day] = dateStr.split('-');
const date = new Date(Number(year), Number(month) - 1, Number(day));
return date.toLocaleDateString('pt-PT', { timeZone: 'Europe/Lisbon', weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
} catch {
return dateStr;
}
}
</script>
<svelte:head>
@@ -49,14 +62,32 @@
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-3 mb-4">
<div>
<h2 class="fw-bold text-dark mb-1">Registo de Entregas</h2>
<p class="text-muted mb-0">Selecione o número do beneficiário para registar a entrega do cabaz de hoje</p>
{#if isToday}
<p class="text-muted mb-0">Selecione o número do beneficiário para registar a entrega do cabaz de hoje</p>
{:else}
<p class="text-muted mb-0">Visualização de registos de entrega para a data selecionada (alterações desabilitadas)</p>
{/if}
</div>
<div class="bg-dark text-white px-3 py-2 rounded-3 shadow-sm d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar-event" viewBox="0 0 16 16">
<path d="M11 6.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5z"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
</svg>
<span class="fw-semibold">{new Date().toLocaleDateString('pt-PT', { timeZone: 'Europe/Lisbon', weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</span>
<div class="position-relative d-inline-block">
<!-- original style and layout wrapper -->
<div class="bg-dark text-white px-3 py-2 rounded-3 shadow-sm d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar-event" viewBox="0 0 16 16">
<path d="M11 6.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5z"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
</svg>
<span class="fw-semibold text-capitalize">{formatDateLong(data.selectedDate)}</span>
</div>
<!-- Overlay input overlayed on top -->
<form method="GET" class="position-absolute top-0 start-0 w-100 h-100 m-0 opacity-0" style="z-index: 5;">
<input
type="date"
name="date"
value={data.selectedDate}
onchange={(e) => e.currentTarget.form?.submit()}
class="datepicker-overlay w-100 h-100 border-0 cursor-pointer position-absolute top-0 start-0"
style="cursor: pointer; z-index: 5;"
/>
</form>
</div>
</div>
@@ -92,9 +123,9 @@
{@const isDelivered = deliveredSet.has(beneficiary.id)}
<button
type="button"
class="btn-grid-beneficiary d-flex flex-column align-items-center justify-content-center rounded-4 border-2 transition shadow-sm {isDelivered ? 'delivered' : 'active'}"
onclick={() => openConfirmation(beneficiary)}
disabled={isDelivered}
class="btn-grid-beneficiary d-flex flex-column align-items-center justify-content-center rounded-4 border-2 transition shadow-sm {isDelivered ? 'delivered' : isToday ? 'active' : 'disabled-past'}"
onclick={() => isToday && openConfirmation(beneficiary)}
disabled={isDelivered || !isToday}
aria-label="Beneficiário número {beneficiary.number} - {beneficiary.name}"
>
<span class="beneficiary-number fw-bold mb-0">{beneficiary.number ?? '-'}</span>
@@ -117,7 +148,7 @@
<!-- Today's Recent Deliveries -->
<div class="card border-0 shadow-sm rounded-4 overflow-hidden bg-white">
<div class="card-header border-0 bg-light py-3 px-4">
<h5 class="fw-bold text-dark mb-0">Entregas de Hoje</h5>
<h5 class="fw-bold text-dark mb-0">{isToday ? 'Entregas de Hoje' : 'Entregas Registadas'}</h5>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
@@ -127,14 +158,16 @@
<th class="py-3">Nome do Beneficiário</th>
<th class="py-3">Turno</th>
<th class="py-3" style="width: 180px;">Hora do Registo</th>
<th class="py-3 px-4 text-end" style="width: 100px;">Ações</th>
{#if isToday}
<th class="py-3 px-4 text-end" style="width: 100px;">Ações</th>
{/if}
</tr>
</thead>
<tbody>
{#if data.todayDeliveries.length === 0}
<tr>
<td colspan="5" class="text-center py-4 text-muted">
Nenhum cabaz entregue hoje.
<td colspan={isToday ? 5 : 4} class="text-center py-4 text-muted">
Nenhum cabaz entregue nesta data.
</td>
</tr>
{:else}
@@ -154,19 +187,21 @@
<td class="text-secondary fw-medium">
{formatTime(delivery.createdAt)}
</td>
<td class="px-4 text-end">
<button
type="button"
class="btn btn-sm btn-link text-danger p-0 border-0 align-middle transition"
title="Apagar Registo"
onclick={() => openDeleteConfirmation(delivery)}
disabled={isLoading}
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-trash3" viewBox="0 0 16 16">
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5"/>
</svg>
</button>
</td>
{#if isToday}
<td class="px-4 text-end">
<button
type="button"
class="btn btn-sm btn-link text-danger p-0 border-0 align-middle transition"
title="Apagar Registo"
onclick={() => openDeleteConfirmation(delivery)}
disabled={isLoading}
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-trash3" viewBox="0 0 16 16">
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5"/>
</svg>
</button>
</td>
{/if}
</tr>
{/each}
{/if}
@@ -332,6 +367,13 @@
cursor: not-allowed;
}
.btn-grid-beneficiary.disabled-past {
background-color: #f8f9fa;
border-color: #dee2e6;
color: #adb5bd;
cursor: not-allowed;
}
.beneficiary-number {
font-size: 1.5rem;
line-height: 1;
@@ -340,4 +382,16 @@
.transition {
transition: all 0.15s ease-in-out;
}
.datepicker-overlay::-webkit-calendar-picker-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
</style>
+151
View File
@@ -0,0 +1,151 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw redirect(303, '/login');
}
return {};
};
export const actions: Actions = {
generate: async ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
return fail(403, { error: 'Não autorizado.' });
}
try {
// 1. Clear the deliveries table
db.delete(schema.deliveries).run();
// 2. Ensure at least one shift exists
let shift = db.select().from(schema.shifts).get();
if (!shift) {
const nowTs = Date.now();
db.insert(schema.shifts)
.values({
code: 'T1',
startTime: '14:30',
endTime: '16:30',
days: '2,4',
createdAt: nowTs,
updatedAt: nowTs
})
.run();
shift = db.select().from(schema.shifts).get();
}
if (!shift) {
return fail(500, { error: 'Não foi possível encontrar ou criar um turno padrão.' });
}
// 3. Ensure test beneficiaries #1, #2, #3 exist
const testCreationTime = Date.now() - 35 * 24 * 60 * 60 * 1000;
for (const num of [1, 2, 3]) {
const existing = db
.select()
.from(schema.beneficiaries)
.where(eq(schema.beneficiaries.number, num))
.get();
if (!existing) {
db.insert(schema.beneficiaries)
.values({
number: num,
name: `Beneficiário Teste #${num}`,
contact: `91234560${num}`,
householdSize: num,
status: 'ativo',
isParent: true,
createdAt: testCreationTime,
updatedAt: Date.now()
})
.run();
} else {
db.update(schema.beneficiaries)
.set({
status: 'ativo',
isParent: true,
createdAt: testCreationTime,
updatedAt: Date.now()
})
.where(eq(schema.beneficiaries.id, existing.id))
.run();
}
}
// 4. Fetch all active parent beneficiaries
const activeBeneficiaries = db
.select()
.from(schema.beneficiaries)
.where(
and(
eq(schema.beneficiaries.status, 'ativo'),
eq(schema.beneficiaries.isParent, true)
)
)
.all();
// 5. Calculate Tuesdays & Thursdays in the last 30 days
const dates: string[] = [];
const today = new Date();
const todayStr = today.toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
for (let i = 1; i <= 30; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
const dayOfWeek = d.getDay();
if (dayOfWeek === 2 || dayOfWeek === 4) {
const dateStr = d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' });
if (dateStr < todayStr) {
dates.push(dateStr);
}
}
}
dates.sort();
if (dates.length === 0) {
return fail(400, { error: 'Não foram encontradas datas de entregas válidas nos últimos 30 dias.' });
}
let insertedCount = 0;
for (const b of activeBeneficiaries) {
let datesToInsert = [...dates];
if (b.number === 1) {
datesToInsert = dates.slice(1);
} else if (b.number === 2) {
datesToInsert = dates.slice(2);
} else if (b.number === 3) {
datesToInsert = dates.slice(3);
}
for (const dateStr of datesToInsert) {
db.insert(schema.deliveries)
.values({
beneficiaryId: b.id,
shiftId: shift.id,
date: dateStr,
createdAt: Date.now()
})
.run();
insertedCount++;
}
}
return {
success: true,
totalDays: dates.length,
insertedCount
};
} catch (err: any) {
console.error('Error generating dummy data:', err);
return fail(500, { error: err.message || 'Erro interno ao gerar dados.' });
}
}
};
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
let isLoading = $state(false);
</script>
<svelte:head>
<title>Dummy Data Generator - RefoodOne</title>
</svelte:head>
<div class="container py-4">
<div class="mb-4">
<h2 class="fw-bold text-dark mb-1">Dummy Data Generator</h2>
<p class="text-muted mb-0">Ferramenta para redefinir e carregar dados fictícios de entregas</p>
</div>
{#if form?.error}
<div class="alert alert-danger border-0 shadow-sm rounded-3 mb-4 d-flex align-items-center gap-2" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-exclamation-triangle-fill text-danger" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
<div>
{form.error}
</div>
</div>
{/if}
{#if form?.success}
<div class="alert alert-success border-0 shadow-sm rounded-3 mb-4 d-flex align-items-center gap-2" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-check-circle-fill text-success" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
<div>
Dados gerados com sucesso! Foram limpos todos os registos e inseridas <strong>{form.insertedCount} entregas</strong> ao longo de <strong>{form.totalDays} dias de entrega</strong>.
</div>
</div>
{/if}
<div class="card border-0 shadow-sm rounded-4 p-4 bg-white mb-4" style="max-width: 700px;">
<h5 class="fw-bold text-dark mb-3">Como funciona a geração de dados?</h5>
<div class="alert alert-warning border-0 rounded-3 mb-4" role="alert">
<div class="d-flex gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-exclamation-triangle-fill flex-shrink-0" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
<div>
<strong>Atenção:</strong> Esta ação irá apagar permanentemente todas as entregas atuais registadas na base de dados antes de carregar o conjunto de teste.
</div>
</div>
</div>
<ul class="list-group list-group-flush mb-4">
<li class="list-group-item border-0 px-0 py-2.5 d-flex align-items-start gap-2">
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-circle p-1"></span>
<div>
<strong>Limpeza Completa:</strong> Apaga todos os registos da tabela <code>deliveries</code>.
</div>
</li>
<li class="list-group-item border-0 px-0 py-2.5 d-flex align-items-start gap-2">
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-circle p-1"></span>
<div>
<strong>Garantia de Turno:</strong> Cria um turno padrão (T1) às Terças e Quintas caso nenhum esteja configurado.
</div>
</li>
<li class="list-group-item border-0 px-0 py-2.5 d-flex align-items-start gap-2">
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-circle p-1"></span>
<div>
<strong>Garantia de Beneficiários:</strong> Cria ou ativa os beneficiários <strong>#1, #2 e #3</strong> (configurados como principais/agregado e com data de criação superior a 35 dias).
</div>
</li>
<li class="list-group-item border-0 px-0 py-2.5 d-flex align-items-start gap-2">
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-circle p-1"></span>
<div>
<strong>Distribuição de Ausências nos Últimos 30 Dias:</strong>
<ul class="mt-1 mb-0 ps-3 small text-secondary">
<li>O beneficiário <strong>#1</strong> faltará exatamente a <strong>1 entrega</strong>.</li>
<li>O beneficiário <strong>#2</strong> faltará exatamente a <strong>2 entregas</strong>.</li>
<li>O beneficiário <strong>#3</strong> faltará exatamente a <strong>3 entregas</strong>.</li>
<li>Os restantes beneficiários ativos receberão em todas as datas (0 faltas).</li>
</ul>
</div>
</li>
</ul>
<form
method="POST"
action="?/generate"
use:enhance={() => {
isLoading = true;
return async ({ update }) => {
isLoading = false;
await update();
};
}}
onsubmit={(e) => {
if (!confirm('Tem a certeza que deseja APAGAR todas as entregas e gerar os dados de teste?')) {
e.preventDefault();
}
}}
>
<div class="d-flex align-items-center gap-3">
<button
type="submit"
class="btn btn-danger rounded-3 px-4 py-2.5 fw-bold"
disabled={isLoading}
>
{#if isLoading}
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
A Gerar Dados...
{:else}
Generate Data
{/if}
</button>
{#if form?.success}
<a href="/admin/ausencias" class="btn btn-outline-success rounded-3 px-4 py-2.5 fw-semibold">
Ver Relatório de Ausências →
</a>
{/if}
</div>
</form>
</div>
</div>