From 8f519873427bd17eb6372da6941b8d2ade6f5d83 Mon Sep 17 00:00:00 2001 From: Duarte Date: Thu, 4 Jun 2026 18:51:22 +0100 Subject: [PATCH] =?UTF-8?q?Ecr=C3=A3o=20das=20aus=C3=AAncias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks.server.ts | 2 +- src/routes/+layout.server.ts | 8 +- src/routes/+layout.svelte | 24 ++ src/routes/+page.svelte | 11 + src/routes/admin/ausencias/+page.server.ts | 144 ++++++++++++ src/routes/admin/ausencias/+page.svelte | 237 ++++++++++++++++++++ src/routes/entregas/+page.server.ts | 31 ++- src/routes/entregas/+page.svelte | 110 ++++++--- src/routes/super/dummy-data/+page.server.ts | 151 +++++++++++++ src/routes/super/dummy-data/+page.svelte | 125 +++++++++++ 10 files changed, 807 insertions(+), 36 deletions(-) create mode 100644 src/routes/admin/ausencias/+page.server.ts create mode 100644 src/routes/admin/ausencias/+page.svelte create mode 100644 src/routes/super/dummy-data/+page.server.ts create mode 100644 src/routes/super/dummy-data/+page.svelte diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 918f8f6..fb1d3a5 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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)}`); } diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index ec238fd..845af7c 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -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 }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a6ed2cd..9a4aa7b 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -42,6 +42,9 @@
  • Beneficiários
  • +
  • + Ausências +
  • Turnos
  • @@ -54,6 +57,27 @@ + {#if data.user.role === 'admin' && data.isLocalhost} + + {/if}
    diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b56e048..51a9dc9 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -37,6 +37,17 @@ Beneficiários + + +
    + + + + +
    + Ausências +
    +
    diff --git a/src/routes/admin/ausencias/+page.server.ts b/src/routes/admin/ausencias/+page.server.ts new file mode 100644 index 0000000..e505989 --- /dev/null +++ b/src/routes/admin/ausencias/+page.server.ts @@ -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`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.' + }; + } +}; diff --git a/src/routes/admin/ausencias/+page.svelte b/src/routes/admin/ausencias/+page.svelte new file mode 100644 index 0000000..caa5088 --- /dev/null +++ b/src/routes/admin/ausencias/+page.svelte @@ -0,0 +1,237 @@ + + + + Ausências de Beneficiários - RefoodOne + + +
    +
    +

    Ausências de Beneficiários

    +

    Controlo de faltas e assiduidade no período selecionado (entregas 2 vezes por semana)

    +
    + + {#if data.error} + + {/if} + + +
    +
    +
    +
    +
    + + + + +
    +
    +
    Dias de Entrega Ocorridos
    +

    {data.totalDeliveryDays} dias

    + no período selecionado +
    +
    +
    +
    + +
    +
    +
    +
    + + + +
    +
    +
    Beneficiários com Faltas
    +

    {data.beneficiaries.length}

    + ativos e com faltas registadas +
    +
    +
    +
    + +
    +
    +
    +
    + + + +
    +
    +
    Filtro de Relevância
    +

    ≥ {data.minAbsences} {data.minAbsences === 1 ? 'falta' : 'faltas'}

    + limiar selecionado para aviso +
    +
    +
    +
    +
    + + +
    +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    +
    +
    + + +
    +
    +
    Relatório de Assiduidade
    +
    +
    + + + + + + + + + + + + + + + {#if data.beneficiaries.length === 0} + + + + {:else} + {#each data.beneficiaries as b} + {@const assid = calculateAssiduity(b.receivedCount, b.totalRelevantDeliveries)} + + + + + + + + + + + {/each} + {/if} + +
    NúmeroNomeContactoFaltas / EntregasAssiduidadeDias com Ausência no PeríodoÚltima EntregaAções
    + + + + +

    Nenhum beneficiário com ausências registadas.

    +
    + #{b.number} + + {b.name} + + {b.contact || 'Sem contacto'} + + {b.absencesCount} / {b.totalRelevantDeliveries} + + + {assid}% + + +
    + {#each b.missedDates as date} + + {formatDate(date)} + + {/each} +
    +
    + {b.lastDelivery ? formatDate(b.lastDelivery) : 'Nunca'} + + + + + + Ficha + +
    +
    +
    +
    + + diff --git a/src/routes/entregas/+page.server.ts b/src/routes/entregas/+page.server.ts index 9494f96..7c7c903 100644 --- a/src/routes/entregas/+page.server.ts +++ b/src/routes/entregas/+page.server.ts @@ -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)) diff --git a/src/routes/entregas/+page.svelte b/src/routes/entregas/+page.svelte index 40d61b1..f9a92e9 100644 --- a/src/routes/entregas/+page.svelte +++ b/src/routes/entregas/+page.svelte @@ -6,12 +6,14 @@ let activeModalBeneficiary = $state(null); let activeDeleteDelivery = $state(null); let isLoading = $state(false); + let dateInput = $state(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; + } + } @@ -49,14 +62,32 @@

    Registo de Entregas

    -

    Selecione o número do beneficiário para registar a entrega do cabaz de hoje

    + {#if isToday} +

    Selecione o número do beneficiário para registar a entrega do cabaz de hoje

    + {:else} +

    Visualização de registos de entrega para a data selecionada (alterações desabilitadas)

    + {/if}
    -
    - - - - - {new Date().toLocaleDateString('pt-PT', { timeZone: 'Europe/Lisbon', weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} +
    + +
    + + + + + {formatDateLong(data.selectedDate)} +
    + +
    + 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;" + /> +
    @@ -92,9 +123,9 @@ {@const isDelivered = deliveredSet.has(beneficiary.id)}