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
+7
View File
@@ -1,2 +1,9 @@
# Drizzle
DATABASE_URL=local.db
# Odoo API
ODOO_URL=
ODOO_DB=
ODOO_KEY=
ODOO_USER=
ODOO_COMPANY_ID=
+2 -3
View File
@@ -26,6 +26,5 @@ Main modules
* Track delivery
* List deliveries
## Integrações Externas
* **Beneficiários**: O repositório dos beneficiários é a API json-rpc da Odoo v14.
+61
View File
@@ -0,0 +1,61 @@
CREATE TABLE `beneficiaries` (
`id` text PRIMARY KEY NOT NULL,
`number` integer,
`name` text NOT NULL,
`contact` text NOT NULL,
`household_size` integer DEFAULT 1 NOT NULL,
`observations` text,
`status` text DEFAULT 'ativo' NOT NULL,
`odoo_id` integer,
`odoo_number` text,
`parent_odoo_id` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `odoo_id_idx` ON `beneficiaries` (`odoo_id`);--> statement-breakpoint
CREATE INDEX `number_idx` ON `beneficiaries` (`number`);--> statement-breakpoint
CREATE TABLE `deliveries` (
`id` text PRIMARY KEY NOT NULL,
`beneficiary_id` text NOT NULL,
`shift_id` text NOT NULL,
`date` text NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiaries`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`shift_id`) REFERENCES `shifts`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `shifts` (
`id` text PRIMARY KEY NOT NULL,
`code` text NOT NULL,
`start_time` text NOT NULL,
`end_time` text NOT NULL,
`days` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `shifts_code_unique` ON `shifts` (`code`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`role` text NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
+404
View File
@@ -0,0 +1,404 @@
{
"version": "6",
"dialect": "sqlite",
"id": "08266197-0b38-43f0-a6c0-19ac410008f8",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"beneficiaries": {
"name": "beneficiaries",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"number": {
"name": "number",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"household_size": {
"name": "household_size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"observations": {
"name": "observations",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'ativo'"
},
"odoo_id": {
"name": "odoo_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"odoo_number": {
"name": "odoo_number",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"parent_odoo_id": {
"name": "parent_odoo_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"odoo_id_idx": {
"name": "odoo_id_idx",
"columns": [
"odoo_id"
],
"isUnique": true
},
"number_idx": {
"name": "number_idx",
"columns": [
"number"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"deliveries": {
"name": "deliveries",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"beneficiary_id": {
"name": "beneficiary_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"shift_id": {
"name": "shift_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"deliveries_beneficiary_id_beneficiaries_id_fk": {
"name": "deliveries_beneficiary_id_beneficiaries_id_fk",
"tableFrom": "deliveries",
"tableTo": "beneficiaries",
"columnsFrom": [
"beneficiary_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deliveries_shift_id_shifts_id_fk": {
"name": "deliveries_shift_id_shifts_id_fk",
"tableFrom": "deliveries",
"tableTo": "shifts",
"columnsFrom": [
"shift_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"shifts": {
"name": "shifts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"code": {
"name": "code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"start_time": {
"name": "start_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_time": {
"name": "end_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"days": {
"name": "days",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"shifts_code_unique": {
"name": "shifts_code_unique",
"columns": [
"code"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1780444363958,
"tag": "0000_light_red_wolf",
"breakpoints": true
}
]
}
+44
View File
@@ -43,6 +43,50 @@ try {
console.error("Migration failed:", err);
}
// Run schema migration if 'odoo_id', 'parent_odoo_id' or 'odoo_number' column is missing in beneficiaries
try {
const tableInfo = client.prepare("PRAGMA table_info(beneficiaries)").all() as any[];
const hasOdooId = tableInfo.some((col) => col.name === 'odoo_id');
const hasParentOdooId = tableInfo.some((col) => col.name === 'parent_odoo_id');
const hasOdooNumber = tableInfo.some((col) => col.name === 'odoo_number');
if (!hasOdooId) {
console.log("Migrating beneficiaries table: adding 'odoo_id' column...");
client.prepare("ALTER TABLE beneficiaries ADD COLUMN odoo_id INTEGER").run();
}
if (!hasParentOdooId) {
console.log("Migrating beneficiaries table: adding 'parent_odoo_id' column...");
client.prepare("ALTER TABLE beneficiaries ADD COLUMN parent_odoo_id INTEGER").run();
}
if (!hasOdooNumber) {
console.log("Migrating beneficiaries table: adding 'odoo_number' column...");
client.prepare("ALTER TABLE beneficiaries ADD COLUMN odoo_number TEXT").run();
}
// Create unique index if it doesn't exist
client.prepare("CREATE UNIQUE INDEX IF NOT EXISTS odoo_id_idx ON beneficiaries(odoo_id)").run();
console.log("Database migration for beneficiaries table completed.");
} catch (err) {
console.error("Migration for beneficiaries failed:", err);
}
// Run schema migration to create settings table if missing
try {
client.prepare(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`).run();
console.log("Database migration for settings table completed.");
} catch (err) {
console.error("Migration for settings failed:", err);
}
export const db = drizzle(client, { schema });
// Seed admin user if the users table is empty (US00)
+19 -3
View File
@@ -1,4 +1,4 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
@@ -19,15 +19,24 @@ export const sessions = sqliteTable('sessions', {
export const beneficiaries = sqliteTable('beneficiaries', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
number: integer('number').notNull().unique(),
number: integer('number'),
name: text('name').notNull(),
contact: text('contact').notNull(),
householdSize: integer('household_size').notNull().default(1),
observations: text('observations'),
status: text('status').notNull().default('ativo'), // 'ativo' | 'inativo'
odooId: integer('odoo_id'),
odooNumber: text('odoo_number'),
parentOdooId: integer('parent_odoo_id'),
isParent: integer('is_parent', { mode: 'boolean' }),
createdAt: integer('created_at').notNull().$defaultFn(() => Date.now()),
updatedAt: integer('updated_at').notNull().$defaultFn(() => Date.now())
});
}, (table) => [
uniqueIndex('odoo_id_idx').on(table.odooId),
index('number_idx').on(table.number)
]);
export const shifts = sqliteTable('shifts', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
@@ -51,3 +60,10 @@ export const deliveries = sqliteTable('deliveries', {
createdAt: integer('created_at').notNull().$defaultFn(() => Date.now())
});
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: integer('updated_at').notNull().$defaultFn(() => Date.now())
});
+260
View File
@@ -0,0 +1,260 @@
import { env } from '$env/dynamic/private';
/**
* Authenticates with the Odoo external API and returns the user's UID.
*/
export async function authenticateOdoo(): Promise<number> {
const url = env.ODOO_URL || 'https://erp.onrefood.com/jsonrpc';
const db = env.ODOO_DB;
const user = env.ODOO_USER;
const key = env.ODOO_KEY;
if (!db || !user || !key) {
throw new Error('Faltam parâmetros de configuração do Odoo no ambiente (.env): ODOO_DB, ODOO_USER, ODOO_KEY');
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
service: 'common',
method: 'authenticate',
args: [db, user, key, {}]
},
id: 1
})
});
if (!response.ok) {
throw new Error(`Erro na chamada HTTP do Odoo: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(`Erro retornado pela API do Odoo: ${JSON.stringify(data.error)}`);
}
// In Odoo, if authentication fails, it returns false (not throwing jsonrpc error always)
const uid = data.result;
if (uid === false || uid === undefined || uid === null) {
throw new Error('Credenciais do Odoo incorretas (UID não retornado).');
}
return uid;
}
/**
* Syncs beneficiaries from Odoo.
*/
import { db } from './db';
import * as schema from './db/schema';
import { eq, sql } from 'drizzle-orm';
export async function syncBeneficiaries(): Promise<{ success: boolean; count: number; error?: string }> {
try {
const url = env.ODOO_URL || 'https://erp.onrefood.com/jsonrpc';
const dbName = env.ODOO_DB;
const user = env.ODOO_USER;
const key = env.ODOO_KEY;
if (!dbName || !user || !key) {
throw new Error('Faltam parâmetros de configuração do Odoo no ambiente (.env): ODOO_DB, ODOO_USER, ODOO_KEY');
}
// 1. Authenticate to get UID
const uid = await authenticateOdoo();
// 2. Get last sync date from database settings
const lastSyncSetting = db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, 'last_beneficiary_sync'))
.get();
const lastSyncTime = lastSyncSetting ? lastSyncSetting.value : '2026-01-01 00:00:00';
const companyIdRaw = env.ODOO_COMPANY_ID;
const companyId = companyIdRaw ? parseInt(companyIdRaw, 10) : 107;
// 3. Make execute_kw call to search_read beneficiaries
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
service: 'object',
method: 'execute_kw',
args: [
dbName,
uid,
key,
'res.beneficiary',
'search_read',
[[
['write_date', '>=', lastSyncTime]
]],
{
context: {
allowed_company_ids: [companyId],
active_test: false
}
}
]
},
id: 2
})
});
if (!response.ok) {
throw new Error(`Erro na chamada HTTP do Odoo search_read: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(`Erro retornado pela API do Odoo search_read: ${JSON.stringify(data.error)}`);
}
const results = data.result;
if (!Array.isArray(results)) {
throw new Error('Formato inválido de resposta do Odoo (result não é um array)');
}
let syncCount = 0;
const now = Date.now();
// Helper to extract trailing digits as integer
const extractNumber = (odooNum: string | null | undefined): number | null => {
if (!odooNum) return null;
const match = odooNum.match(/\d+$/);
return match ? parseInt(match[0], 10) : null;
};
// 4. Process and save/update each beneficiary
for (const result of results) {
const odooId = result.id;
const name = result.name || '';
const contact = result.phone || result.mobile || '';
const odooNumber = result.number || '';
const parsedNumber = extractNumber(odooNumber);
const parentOdooId = Array.isArray(result.parent_id) ? result.parent_id[0] : null;
const isParent = parentOdooId === null;
const activeStatus = result.active !== false ? 'ativo' : 'inativo';
// Check if beneficiary with odooId already exists
const existingById = db
.select()
.from(schema.beneficiaries)
.where(eq(schema.beneficiaries.odooId, odooId))
.get();
if (existingById) {
// Update existing
db.update(schema.beneficiaries)
.set({
name,
contact,
number: parsedNumber !== null ? parsedNumber : existingById.number,
odooNumber,
parentOdooId,
isParent,
status: activeStatus,
updatedAt: now
})
.where(eq(schema.beneficiaries.id, existingById.id))
.run();
} else {
// Insert new
db.insert(schema.beneficiaries)
.values({
number: parsedNumber,
name,
contact,
odooId,
odooNumber,
parentOdooId,
isParent,
status: activeStatus,
createdAt: now,
updatedAt: now
})
.run();
}
syncCount++;
}
// Update household size for all main beneficiaries (parentOdooId is null)
// householdSize = 1 + count of dependents (other beneficiaries with parentOdooId pointing to this odooId)
db.run(
sql`
UPDATE beneficiaries
SET household_size = 1 + (
SELECT COUNT(*)
FROM beneficiaries AS dependents
WHERE dependents.parent_odoo_id = beneficiaries.odoo_id
)
WHERE parent_odoo_id IS NULL AND odoo_id IS NOT NULL
`
);
// Set household size of dependents (parentOdooId is not null) to 1
db.run(
sql`
UPDATE beneficiaries
SET household_size = 1
WHERE parent_odoo_id IS NOT NULL AND odoo_id IS NOT NULL
`
);
// 5. Update last sync date to current time in UTC format (YYYY-MM-DD HH:MM:SS)
const formatUTC = (date: Date): string => {
const pad = (n: number) => n.toString().padStart(2, '0');
return (
date.getUTCFullYear() +
'-' +
pad(date.getUTCMonth() + 1) +
'-' +
pad(date.getUTCDate()) +
' ' +
pad(date.getUTCHours()) +
':' +
pad(date.getUTCMinutes()) +
':' +
pad(date.getUTCSeconds())
);
};
const newSyncTime = formatUTC(new Date());
// Upsert settings table
const hasSetting = db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, 'last_beneficiary_sync'))
.get();
if (hasSetting) {
db.update(schema.settings)
.set({ value: newSyncTime, updatedAt: now })
.where(eq(schema.settings.key, 'last_beneficiary_sync'))
.run();
} else {
db.insert(schema.settings)
.values({ key: 'last_beneficiary_sync', value: newSyncTime, updatedAt: now })
.run();
}
return { success: true, count: syncCount };
} catch (error: any) {
console.error('Erro na sincronização de beneficiários:', error);
return { success: false, count: 0, error: error.message || 'Erro desconhecido' };
}
}
+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 };
}
};
+145 -6
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,11 +59,51 @@
</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>
<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"/>
@@ -36,6 +111,7 @@
Novo Beneficiário
</a>
</div>
</div>
<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">
@@ -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
});