Integração com Odoo

This commit is contained in:
Duarte
2026-06-03 01:13:58 +01:00
parent 75bc71eb39
commit b1cc6368a1
13 changed files with 1007 additions and 27 deletions
+23 -2
View File
@@ -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 };
}
};
+151 -12
View File
@@ -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>
+8 -3
View File
@@ -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();
+13 -3
View File
@@ -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>
+2 -1
View File
@@ -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
});