Integração com Odoo
This commit is contained in:
@@ -8,7 +8,10 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
const status = url.searchParams.get('status') || '';
|
||||
|
||||
try {
|
||||
let query = db.select().from(schema.beneficiaries);
|
||||
let query = db
|
||||
.select()
|
||||
.from(schema.beneficiaries)
|
||||
.where(eq(schema.beneficiaries.isParent, true));
|
||||
|
||||
// We can fetch all and filter in memory since table size is guaranteed to be small (<20 tables, low concurrency).
|
||||
// This keeps SQLite query logic extremely simple and readable.
|
||||
@@ -19,7 +22,7 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
list = list.filter(
|
||||
(b) =>
|
||||
b.name.toLowerCase().includes(searchLower) ||
|
||||
b.number.toString().includes(searchLower) ||
|
||||
(b.number !== null && b.number.toString().includes(searchLower)) ||
|
||||
(b.contact && b.contact.includes(searchLower))
|
||||
);
|
||||
}
|
||||
@@ -43,3 +46,21 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
import { syncBeneficiaries } from '$lib/server/odoo';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
sync: async ({ locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
return fail(403, { error: 'Acesso negado. Apenas administradores podem sincronizar com o Odoo.' });
|
||||
}
|
||||
const result = await syncBeneficiaries();
|
||||
if (!result.success) {
|
||||
return fail(500, { error: result.error || 'Erro ao sincronizar com o Odoo.' });
|
||||
}
|
||||
return { success: true, count: result.count };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data } = $props();
|
||||
let { data, form } = $props();
|
||||
|
||||
let syncing = $state(false);
|
||||
|
||||
let sortBy = $state<'number' | 'name' | 'householdSize'>('number');
|
||||
let sortOrder = $state<'asc' | 'desc'>('asc');
|
||||
|
||||
function toggleSort(field: 'number' | 'name' | 'householdSize') {
|
||||
if (sortBy === field) {
|
||||
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortBy = field;
|
||||
sortOrder = 'asc';
|
||||
}
|
||||
}
|
||||
|
||||
const sortedBeneficiaries = $derived.by(() => {
|
||||
const list = [...(data.beneficiaries || [])];
|
||||
return list.sort((a, b) => {
|
||||
let valA = a[sortBy];
|
||||
let valB = b[sortBy];
|
||||
|
||||
if (valA === null || valA === undefined) return 1;
|
||||
if (valB === null || valB === undefined) return -1;
|
||||
|
||||
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||
return sortOrder === 'asc'
|
||||
? valA.localeCompare(valB, 'pt-PT')
|
||||
: valB.localeCompare(valA, 'pt-PT');
|
||||
} else {
|
||||
return sortOrder === 'asc'
|
||||
? (valA as number) - (valB as number)
|
||||
: (valB as number) - (valA as number);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const successMessage = $derived(page.url.searchParams.get('success'));
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -24,17 +59,58 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<div class="alert alert-success border-0 shadow-sm rounded-3 d-flex align-items-center gap-2 mb-4" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-check-circle-fill text-success" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||
</svg>
|
||||
<div>
|
||||
Sincronização concluída com sucesso! {form.count} beneficiários atualizados/importados.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<div class="alert alert-danger border-0 shadow-sm rounded-3 d-flex align-items-center gap-2 mb-4" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-exclamation-triangle-fill text-danger" viewBox="0 0 16 16">
|
||||
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
||||
</svg>
|
||||
<div>
|
||||
{form.error}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold text-dark mb-1">Beneficiários</h2>
|
||||
<p class="text-muted mb-0">Gerir a listagem de beneficiários e agregados familiares</p>
|
||||
</div>
|
||||
<a href="/admin/beneficiarios/novo" class="btn btn-success btn-lg d-flex align-items-center gap-2 rounded-3 shadow-sm border-0" style="background-color: var(--refood-primary, #FCB515);">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2"/>
|
||||
</svg>
|
||||
Novo Beneficiário
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
{#if data.user?.role === 'admin'}
|
||||
<form method="POST" action="?/sync" use:enhance={() => {
|
||||
syncing = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
syncing = false;
|
||||
};
|
||||
}} onsubmit={(e) => { if (!confirm('Tem a certeza que deseja sincronizar os beneficiários com o Odoo?')) e.preventDefault(); }}>
|
||||
<button type="submit" class="btn btn-outline-sync btn-lg d-flex align-items-center gap-2 rounded-3 shadow-sm" disabled={syncing}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-arrow-repeat {syncing ? 'spin' : ''}" viewBox="0 0 16 16">
|
||||
<path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/>
|
||||
<path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/>
|
||||
</svg>
|
||||
{syncing ? 'A sincronizar...' : 'Sincronizar Odoo'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
<a href="/admin/beneficiarios/novo" class="btn btn-success btn-lg d-flex align-items-center gap-2 rounded-3 shadow-sm border-0" style="background-color: var(--refood-primary, #FCB515);">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2"/>
|
||||
</svg>
|
||||
Novo Beneficiário
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm rounded-4 p-3 mb-4 bg-white">
|
||||
@@ -69,20 +145,44 @@
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm rounded-4 overflow-hidden bg-white">
|
||||
<div class="card-header border-0 bg-light py-3 px-4 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<h5 class="fw-bold text-dark mb-0">Lista de Beneficiários</h5>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light text-secondary fw-semibold">
|
||||
<tr>
|
||||
<th class="px-4 py-3" style="width: 120px;">Número</th>
|
||||
<th class="py-3">Nome</th>
|
||||
<th class="px-4 py-3 sortable-header" style="width: 120px;" onclick={() => toggleSort('number')}>
|
||||
<div class="d-flex align-items-center gap-1 select-none">
|
||||
Número
|
||||
{#if sortBy === 'number'}
|
||||
<span class="sort-arrow">{sortOrder === 'asc' ? '▲' : '▼'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th class="py-3 sortable-header" onclick={() => toggleSort('name')}>
|
||||
<div class="d-flex align-items-center gap-1 select-none">
|
||||
Nome
|
||||
{#if sortBy === 'name'}
|
||||
<span class="sort-arrow">{sortOrder === 'asc' ? '▲' : '▼'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th class="py-3">Contacto</th>
|
||||
<th class="py-3 text-center" style="width: 120px;">Agregado</th>
|
||||
<th class="py-3 text-center sortable-header" style="width: 120px;" onclick={() => toggleSort('householdSize')}>
|
||||
<div class="d-flex align-items-center justify-content-center gap-1 select-none">
|
||||
Agregado
|
||||
{#if sortBy === 'householdSize'}
|
||||
<span class="sort-arrow">{sortOrder === 'asc' ? '▲' : '▼'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th class="py-3" style="width: 120px;">Estado</th>
|
||||
<th class="px-4 py-3 text-end" style="width: 120px;">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if data.beneficiaries.length === 0}
|
||||
{#if sortedBeneficiaries.length === 0}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-5 text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="currentColor" class="bi bi-people mb-2 text-black-50" viewBox="0 0 16 16">
|
||||
@@ -92,7 +192,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each data.beneficiaries as beneficiary}
|
||||
{#each sortedBeneficiaries as beneficiary}
|
||||
<tr>
|
||||
<td class="px-4 fw-bold text-secondary">
|
||||
#{beneficiary.number}
|
||||
@@ -129,3 +229,42 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sortable-header {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
.sortable-header:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.select-none {
|
||||
user-select: none;
|
||||
}
|
||||
.sort-arrow {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-primary, #2c562e);
|
||||
}
|
||||
.btn-outline-sync {
|
||||
color: #2c562e;
|
||||
border-color: #2c562e;
|
||||
background-color: transparent;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.btn-outline-sync:hover:not(:disabled) {
|
||||
color: #ffffff;
|
||||
background-color: #2c562e;
|
||||
border-color: #2c562e;
|
||||
}
|
||||
.spin {
|
||||
animation: spin-animation 1s infinite linear;
|
||||
}
|
||||
@keyframes spin-animation {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import * as schema from '$lib/server/db/schema';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { eq, and, asc, isNull } from 'drizzle-orm';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
@@ -12,11 +12,16 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
const todayStr = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Lisbon' }); // 'YYYY-MM-DD'
|
||||
|
||||
try {
|
||||
// Fetch active beneficiaries sorted by their unique number
|
||||
// Fetch active beneficiaries sorted by their unique number, who are main beneficiaries (no parentOdooId)
|
||||
const activeBeneficiaries = db
|
||||
.select()
|
||||
.from(schema.beneficiaries)
|
||||
.where(eq(schema.beneficiaries.status, 'ativo'))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.beneficiaries.status, 'ativo'),
|
||||
isNull(schema.beneficiaries.parentOdooId)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(schema.beneficiaries.number))
|
||||
.all();
|
||||
|
||||
|
||||
@@ -68,7 +68,17 @@
|
||||
|
||||
<!-- Beneficiaries Grid -->
|
||||
<div class="card border-0 shadow-sm rounded-4 p-4 mb-4 bg-white">
|
||||
<h5 class="fw-bold text-secondary mb-3">Selecione o Beneficiário</h5>
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||
<h5 class="fw-bold text-secondary mb-0">Selecione o Beneficiário</h5>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-2.5 py-1 rounded-pill" style="font-size: 0.75rem;">
|
||||
{deliveredSet.size} entregues
|
||||
</span>
|
||||
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle px-2.5 py-1 rounded-pill" style="font-size: 0.75rem;">
|
||||
{data.beneficiaries.length - deliveredSet.size} por entregar
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if data.beneficiaries.length === 0}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-person-slash mb-2 text-black-50" viewBox="0 0 16 16">
|
||||
@@ -87,12 +97,12 @@
|
||||
disabled={isDelivered}
|
||||
aria-label="Beneficiário número {beneficiary.number} - {beneficiary.name}"
|
||||
>
|
||||
<span class="beneficiary-number fw-bold mb-0">{beneficiary.number}</span>
|
||||
<span class="beneficiary-number fw-bold mb-0">{beneficiary.number ?? '-'}</span>
|
||||
<span class="beneficiary-first-name text-truncate w-100 px-1 {isDelivered ? 'text-success' : 'text-secondary'}" style="font-size: 0.7rem; font-weight: 500; text-align: center;">
|
||||
{beneficiary.name.split(' ')[0]}
|
||||
</span>
|
||||
{#if isDelivered}
|
||||
<span class="delivery-status-icon mt-0.5">
|
||||
<span class="delivery-status-icon mt-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-check-circle-fill text-success" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||
</svg>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
// If already logged in, redirect them
|
||||
@@ -67,7 +68,7 @@ export const actions: Actions = {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
secure: !dev,
|
||||
maxAge: 60 * 60 * 24 * 7 // 7 days in seconds
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user