diff --git a/.gitignore b/.gitignore index 8ec3bd6..3a72a87 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ env/ # IDEs .vscode/ .idea/ +.vs/ *.swp *~ .DS_Store diff --git a/README.md b/README.md index bcdad6a..f0db051 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,21 @@ Demo funzionante di un gestionale ERP ispirato a **Mago4**, realizzato con **Pyt ## 🎯 Caratteristiche -- **Dashboard interattivo** con KPI e widget -- **Gestione Clienti** - Anagrafica e crediti -- **Gestione Fornitori** - Anagrafica e debiti -- **Catalogo Articoli** - Inventario con alert sottosorta -- **Ordini Vendita** - Creazione e tracciamento -- **Ordini Acquisto** - Gestione forniture -- **Magazzino** - Inventario con controllo giacenze -- **API REST** completamente documentata -- **Database in-memoria** per demo (facilmente migrabile a SQL) -- **UI ispirata a Mago4** - Design moderno con Material Icons +- **Interfaccia fedele a Mago4** — header blu, sidebar a moduli, menu a card con breadcrumb e tab, form documento con tab laterali e griglia righe +- **Cruscotto aziendale** orientato alle scadenze: banner allerta, KPI pagamenti/incassi scaduti, scadenze imminenti, **Cose da Fare** (paga/sollecita/ordina/conferma), **previsione di cassa** a 30 giorni, controllo fido clienti +- **Anagrafiche** — Clienti, Fornitori, Articoli (con P.IVA, codice fiscale, UM, IVA) +- **Vendite** — Ordini Cliente e **Fatturazione**: Fatture Immediate, DDT, Note di Credito, ProForma con calcolo imponibile/IVA/totale e stati documento (bozza → stampato → emesso) +- **Acquisti** — Ordini Fornitore +- **Amministrazione** — **Scadenzario** (partite aperte): incassi/pagamenti con data scadenza, stato (scaduta/in scadenza/aperta/saldata), registrazione incasso/pagamento, previsione di cassa +- **Logistica/Magazzino** — **Movimenti di magazzino** con causali carico/scarico che **aggiornano realmente le giacenze**, **Causali** (DebitCreditSign), Giacenze e valorizzazione +- **API REST** completamente documentata (Swagger su `/docs`) +- **Database in-memoria** con logica di business (facilmente migrabile a SQL) + +### Moduli ispirati al codice sorgente Mago4 +La struttura dati e le funzionalità sono modellate sulle tabelle reali di Mago4: +- **Fatturazione**: `MA_SaleDoc` (InvoiceMng) — tipi documento, righe con sconto/IVA, stati `Printed`/`Issued`/`PostedToInventory` +- **Magazzino**: `MA_InventoryEntries` + `MA_InventoryReasons` (Inventory) — movimenti con causali e segno carico/scarico che aggiornano `MA_ItemsBalances` +- **Scadenzario**: `MA_PyblsRcvbls` + Details (AP_AR) — partite con `InstallmentDate`, `Amount`, `DebitCreditSign`, flag `Closed`/`Collected` ## 🚀 Quick Start diff --git a/database.py b/database.py index 279dad8..16c1465 100644 --- a/database.py +++ b/database.py @@ -1,8 +1,21 @@ from datetime import datetime, timedelta -from models import ( - Customer, Supplier, Product, OrderLine, SalesOrder, PurchaseOrder, OrderStatus -) from typing import List, Optional +from models import ( + Customer, Supplier, Product, OrderLine, SalesOrder, PurchaseOrder, OrderStatus, + Invoice, InvoiceLine, DocumentType, DocumentStatus, + InventoryReason, InventoryMovement, InventoryMovementLine, MovementSign, + PaymentSchedule, PaymentScheduleType, + DOCUMENT_TYPE_LABELS, +) + + +def _today() -> datetime: + now = datetime.now() + return datetime(now.year, now.month, now.day) + + +def _round(v: float) -> float: + return round(v + 1e-9, 2) class FakeDatabase: @@ -12,191 +25,327 @@ class FakeDatabase: self.products = self._init_products() self.sales_orders = self._init_sales_orders() self.purchase_orders = self._init_purchase_orders() - self.next_customer_id = max([c.id for c in self.customers]) + 1 - self.next_supplier_id = max([s.id for s in self.suppliers]) + 1 - self.next_product_id = max([p.id for p in self.products]) + 1 - self.next_so_id = max([o.id for o in self.sales_orders]) + 1 - self.next_po_id = max([o.id for o in self.purchase_orders]) + 1 + self.inventory_reasons = self._init_inventory_reasons() + self.invoices = self._init_invoices() + self.inventory_movements = self._init_inventory_movements() + self.payment_schedules = self._init_payment_schedules() + self.next_customer_id = max((c.id for c in self.customers), default=0) + 1 + self.next_supplier_id = max((s.id for s in self.suppliers), default=0) + 1 + self.next_product_id = max((p.id for p in self.products), default=0) + 1 + self.next_so_id = max((o.id for o in self.sales_orders), default=0) + 1 + self.next_po_id = max((o.id for o in self.purchase_orders), default=0) + 1 + self.next_invoice_id = max((i.id for i in self.invoices), default=0) + 1 + self.next_movement_id = max((m.id for m in self.inventory_movements), default=0) + 1 + self.next_schedule_id = max((s.id for s in self.payment_schedules), default=0) + 1 + + # ---------- Seed: Anagrafiche ---------- def _init_customers(self) -> List[Customer]: return [ - Customer( - id=1, code="CUST001", name="Acme Corporation", - email="info@acme.com", phone="+39 02 1234567", - address="Via Roma 10", city="Milano", country="IT", - credit_limit=50000, balance=15000 - ), - Customer( - id=2, code="CUST002", name="TechFlow Industries", - email="contact@techflow.de", phone="+49 30 555888", - address="Hauptstrasse 45", city="Berlin", country="DE", - credit_limit=75000, balance=32000 - ), - Customer( - id=3, code="CUST003", name="European Logistics", - email="sales@eulog.fr", phone="+33 1 4520 3580", - address="Avenue des Champs 20", city="Paris", country="FR", - credit_limit=100000, balance=8500 - ), - Customer( - id=4, code="CUST004", name="Alpine Manufacturing", - email="orders@alpmfg.ch", phone="+41 44 5678900", - address="Industriestrasse 12", city="Zurich", country="CH", - credit_limit=60000, balance=22000 - ), + Customer(id=1, code="CUST001", name="Acme Corporation S.p.A.", + email="info@acme.it", phone="+39 02 1234567", + address="Via Roma 10", city="Milano", country="IT", + vat_number="IT01234567890", fiscal_code="CMRACM80A01F205X", + payment_terms="RB 30 gg FM", credit_limit=50000, balance=15000), + Customer(id=2, code="CUST002", name="TechFlow Industries GmbH", + email="contact@techflow.de", phone="+49 30 555888", + address="Hauptstrasse 45", city="Berlin", country="DE", + vat_number="DE811234567", payment_terms="Bonifico 60 gg", + credit_limit=75000, balance=32000), + Customer(id=3, code="CUST003", name="European Logistics SARL", + email="sales@eulog.fr", phone="+33 1 4520 3580", + address="Avenue des Champs 20", city="Paris", country="FR", + vat_number="FR40123456789", payment_terms="Bonifico 30 gg", + credit_limit=100000, balance=8500), + Customer(id=4, code="CUST004", name="Alpine Manufacturing AG", + email="orders@alpmfg.ch", phone="+41 44 5678900", + address="Industriestrasse 12", city="Zurich", country="CH", + vat_number="CHE116281456", payment_terms="Rimessa diretta", + credit_limit=60000, balance=22000), ] def _init_suppliers(self) -> List[Supplier]: return [ - Supplier( - id=1, code="SUPP001", name="Global Components Ltd", - email="sales@globalcomp.com", phone="+44 20 7946 0958", - address="123 Oxford Street", city="London", country="UK", - payment_terms_days=30, balance=5000 - ), - Supplier( - id=2, code="SUPP002", name="Asia Electronics Co.", - email="export@asiaelec.com", phone="+886 2 8797 3000", - address="Taipei Industrial Park", city="Taipei", country="TW", - payment_terms_days=45, balance=18000 - ), - Supplier( - id=3, code="SUPP003", name="Nordic Materials AB", - email="info@nordicmat.se", phone="+46 8 123 4567", - address="Industrígatan 5", city="Stockholm", country="SE", - payment_terms_days=60, balance=3500 - ), + Supplier(id=1, code="SUPP001", name="Global Components Ltd", + email="sales@globalcomp.com", phone="+44 20 7946 0958", + address="123 Oxford Street", city="London", country="UK", + vat_number="GB123456789", payment_terms_days=30, balance=5000), + Supplier(id=2, code="SUPP002", name="Asia Electronics Co.", + email="export@asiaelec.com", phone="+886 2 8797 3000", + address="Taipei Industrial Park", city="Taipei", country="TW", + vat_number="", payment_terms_days=45, balance=18000), + Supplier(id=3, code="SUPP003", name="Nordic Materials AB", + email="info@nordicmat.se", phone="+46 8 123 4567", + address="Industrigatan 5", city="Stockholm", country="SE", + vat_number="SE556677889901", payment_terms_days=60, balance=3500), ] def _init_products(self) -> List[Product]: return [ - Product( - id=1, code="PROD001", description="Industrial Widget A", - category="Electronics", unit_price=125.50, - quantity_in_stock=250, reorder_level=50, supplier_id=1 - ), - Product( - id=2, code="PROD002", description="Premium Connector Set", - category="Hardware", unit_price=45.00, - quantity_in_stock=180, reorder_level=30, supplier_id=2 - ), - Product( - id=3, code="PROD003", description="Steel Bracket Type B", - category="Structural", unit_price=22.75, - quantity_in_stock=500, reorder_level=100, supplier_id=3 - ), - Product( - id=4, code="PROD004", description="Precision Bearing Set", - category="Mechanical", unit_price=89.99, - quantity_in_stock=120, reorder_level=40, supplier_id=1 - ), - Product( - id=5, code="PROD005", description="Control Module MCU-32", - category="Electronics", unit_price=234.50, - quantity_in_stock=75, reorder_level=20, supplier_id=2 - ), + Product(id=1, code="PROD001", description="Industrial Widget A", + category="Electronics", uom="NR", unit_price=125.50, + purchase_price=85.00, tax_rate=22.0, + quantity_in_stock=250, reorder_level=50, max_stock=500, supplier_id=1), + Product(id=2, code="PROD002", description="Premium Connector Set", + category="Hardware", uom="NR", unit_price=45.00, + purchase_price=28.00, tax_rate=22.0, + quantity_in_stock=180, reorder_level=30, max_stock=300, supplier_id=2), + Product(id=3, code="PROD003", description="Steel Bracket Type B", + category="Structural", uom="KG", unit_price=22.75, + purchase_price=14.50, tax_rate=22.0, + quantity_in_stock=45, reorder_level=100, max_stock=600, supplier_id=3), + Product(id=4, code="PROD004", description="Precision Bearing Set", + category="Mechanical", uom="NR", unit_price=89.99, + purchase_price=55.00, tax_rate=22.0, + quantity_in_stock=120, reorder_level=40, max_stock=250, supplier_id=1), + Product(id=5, code="PROD005", description="Control Module MCU-32", + category="Electronics", uom="NR", unit_price=234.50, + purchase_price=160.00, tax_rate=22.0, + quantity_in_stock=18, reorder_level=20, max_stock=100, supplier_id=2), ] def _init_sales_orders(self) -> List[SalesOrder]: now = datetime.now() return [ - SalesOrder( - id=1, order_number="SO-2024-0001", - order_date=now - timedelta(days=10), - customer_id=1, customer_name="Acme Corporation", - status=OrderStatus.CONFIRMED, - lines=[ - OrderLine(id=1, product_id=1, product_code="PROD001", - product_description="Industrial Widget A", - quantity=10, unit_price=125.50, total_amount=1255.00), - OrderLine(id=2, product_id=4, product_code="PROD004", - product_description="Precision Bearing Set", - quantity=5, unit_price=89.99, total_amount=449.95), - ], - total_amount=1704.95, - delivery_date=now + timedelta(days=15) - ), - SalesOrder( - id=2, order_number="SO-2024-0002", - order_date=now - timedelta(days=5), - customer_id=2, customer_name="TechFlow Industries", - status=OrderStatus.CONFIRMED, - lines=[ - OrderLine(id=3, product_id=5, product_code="PROD005", - product_description="Control Module MCU-32", - quantity=8, unit_price=234.50, total_amount=1876.00), - ], - total_amount=1876.00, - delivery_date=now + timedelta(days=10) - ), - SalesOrder( - id=3, order_number="SO-2024-0003", - order_date=now - timedelta(days=2), - customer_id=3, customer_name="European Logistics", - status=OrderStatus.DRAFT, - lines=[ - OrderLine(id=4, product_id=2, product_code="PROD002", - product_description="Premium Connector Set", - quantity=20, unit_price=45.00, total_amount=900.00), - OrderLine(id=5, product_id=3, product_code="PROD003", - product_description="Steel Bracket Type B", - quantity=15, unit_price=22.75, total_amount=341.25), - ], - total_amount=1241.25, - delivery_date=now + timedelta(days=20) - ), + SalesOrder(id=1, order_number="SO-2024-0001", + order_date=now - timedelta(days=10), + customer_id=1, customer_name="Acme Corporation S.p.A.", + status=OrderStatus.CONFIRMED, + lines=[ + OrderLine(id=1, product_id=1, product_code="PROD001", + product_description="Industrial Widget A", + quantity=10, unit_price=125.50, total_amount=1255.00), + OrderLine(id=2, product_id=4, product_code="PROD004", + product_description="Precision Bearing Set", + quantity=5, unit_price=89.99, total_amount=449.95), + ], + total_amount=1704.95, delivery_date=now + timedelta(days=15)), + SalesOrder(id=2, order_number="SO-2024-0002", + order_date=now - timedelta(days=5), + customer_id=2, customer_name="TechFlow Industries GmbH", + status=OrderStatus.CONFIRMED, + lines=[ + OrderLine(id=3, product_id=5, product_code="PROD005", + product_description="Control Module MCU-32", + quantity=8, unit_price=234.50, total_amount=1876.00), + ], + total_amount=1876.00, delivery_date=now + timedelta(days=10)), + SalesOrder(id=3, order_number="SO-2024-0003", + order_date=now - timedelta(days=2), + customer_id=3, customer_name="European Logistics SARL", + status=OrderStatus.DRAFT, + lines=[ + OrderLine(id=4, product_id=2, product_code="PROD002", + product_description="Premium Connector Set", + quantity=20, unit_price=45.00, total_amount=900.00), + OrderLine(id=5, product_id=3, product_code="PROD003", + product_description="Steel Bracket Type B", + quantity=15, unit_price=22.75, total_amount=341.25), + ], + total_amount=1241.25, delivery_date=now + timedelta(days=20)), ] def _init_purchase_orders(self) -> List[PurchaseOrder]: now = datetime.now() return [ - PurchaseOrder( - id=1, order_number="PO-2024-0001", - order_date=now - timedelta(days=15), - supplier_id=1, supplier_name="Global Components Ltd", - status=OrderStatus.PARTIALLY_RECEIVED, - lines=[ - OrderLine(id=1, product_id=1, product_code="PROD001", - product_description="Industrial Widget A", - quantity=100, unit_price=110.00, total_amount=11000.00), - ], - total_amount=11000.00, - expected_delivery_date=now + timedelta(days=5) - ), - PurchaseOrder( - id=2, order_number="PO-2024-0002", - order_date=now - timedelta(days=8), - supplier_id=2, supplier_name="Asia Electronics Co.", - status=OrderStatus.CONFIRMED, - lines=[ - OrderLine(id=2, product_id=5, product_code="PROD005", - product_description="Control Module MCU-32", - quantity=50, unit_price=195.00, total_amount=9750.00), - ], - total_amount=9750.00, - expected_delivery_date=now + timedelta(days=25) - ), - PurchaseOrder( - id=3, order_number="PO-2024-0003", - order_date=now - timedelta(days=1), - supplier_id=3, supplier_name="Nordic Materials AB", - status=OrderStatus.DRAFT, - lines=[ - OrderLine(id=3, product_id=3, product_code="PROD003", - product_description="Steel Bracket Type B", - quantity=200, unit_price=20.00, total_amount=4000.00), - ], - total_amount=4000.00, - expected_delivery_date=now + timedelta(days=30) - ), + PurchaseOrder(id=1, order_number="PO-2024-0001", + order_date=now - timedelta(days=15), + supplier_id=1, supplier_name="Global Components Ltd", + status=OrderStatus.PARTIALLY_RECEIVED, + lines=[OrderLine(id=1, product_id=1, product_code="PROD001", + product_description="Industrial Widget A", + quantity=100, unit_price=85.00, total_amount=8500.00)], + total_amount=8500.00, expected_delivery_date=now + timedelta(days=5)), + PurchaseOrder(id=2, order_number="PO-2024-0002", + order_date=now - timedelta(days=8), + supplier_id=2, supplier_name="Asia Electronics Co.", + status=OrderStatus.CONFIRMED, + lines=[OrderLine(id=2, product_id=5, product_code="PROD005", + product_description="Control Module MCU-32", + quantity=50, unit_price=160.00, total_amount=8000.00)], + total_amount=8000.00, expected_delivery_date=now + timedelta(days=25)), + PurchaseOrder(id=3, order_number="PO-2024-0003", + order_date=now - timedelta(days=1), + supplier_id=3, supplier_name="Nordic Materials AB", + status=OrderStatus.DRAFT, + lines=[OrderLine(id=3, product_id=3, product_code="PROD003", + product_description="Steel Bracket Type B", + quantity=200, unit_price=14.50, total_amount=2900.00)], + total_amount=2900.00, expected_delivery_date=now + timedelta(days=30)), ] - # CRUD Operations - def get_customers(self) -> List[Customer]: - return self.customers + # ---------- Seed: Magazzino ---------- + def _init_inventory_reasons(self) -> List[InventoryReason]: + return [ + InventoryReason(code="CAR-ACQ", description="Carico da acquisto", + sign=MovementSign.LOAD, fiscal=True), + InventoryReason(code="CAR-RET", description="Reso da cliente", + sign=MovementSign.LOAD, fiscal=True), + InventoryReason(code="CAR-RET+", description="Rettifica inventariale positiva", + sign=MovementSign.LOAD, fiscal=False), + InventoryReason(code="SCA-VEN", description="Scarico per vendita", + sign=MovementSign.UNLOAD, fiscal=True), + InventoryReason(code="SCA-PRO", description="Scarico per produzione", + sign=MovementSign.UNLOAD, fiscal=False), + InventoryReason(code="SCA-RET-", description="Rettifica inventariale negativa", + sign=MovementSign.UNLOAD, fiscal=False), + ] - def get_customer(self, customer_id: int) -> Optional[Customer]: - return next((c for c in self.customers if c.id == customer_id), None) + def _init_inventory_movements(self) -> List[InventoryMovement]: + now = datetime.now() + return [ + InventoryMovement( + id=1, reason_code="CAR-ACQ", reason_description="Carico da acquisto", + sign=MovementSign.LOAD, doc_no="MOV-0001", + doc_date=now - timedelta(days=12), posting_date=now - timedelta(days=12), + storage="MAG01", custsupp_name="Global Components Ltd", + lines=[InventoryMovementLine(line_no=1, item_code="PROD001", + description="Industrial Widget A", uom="NR", + quantity=100, unit_value=85.00, line_amount=8500.00, + location="A-01-03")], + total_amount=8500.00, posted=True, notes="Carico merce da OdA PO-2024-0001"), + InventoryMovement( + id=2, reason_code="SCA-VEN", reason_description="Scarico per vendita", + sign=MovementSign.UNLOAD, doc_no="MOV-0002", + doc_date=now - timedelta(days=6), posting_date=now - timedelta(days=6), + storage="MAG01", custsupp_name="Acme Corporation S.p.A.", + lines=[InventoryMovementLine(line_no=1, item_code="PROD001", + description="Industrial Widget A", uom="NR", + quantity=10, unit_value=125.50, line_amount=1255.00, + location="A-01-03")], + total_amount=1255.00, posted=True, notes="Scarico per fattura FT-2024-0001"), + InventoryMovement( + id=3, reason_code="CAR-RET+", reason_description="Rettifica inventariale positiva", + sign=MovementSign.LOAD, doc_no="MOV-0003", + doc_date=now - timedelta(days=1), posting_date=now - timedelta(days=1), + storage="MAG01", custsupp_name="", + lines=[InventoryMovementLine(line_no=1, item_code="PROD003", + description="Steel Bracket Type B", uom="KG", + quantity=5, unit_value=14.50, line_amount=72.50, + location="B-02-01")], + total_amount=72.50, posted=False, notes="Da confermare"), + ] + + # ---------- Seed: Fatture ---------- + def _build_invoice_lines(self, raw_lines) -> List[InvoiceLine]: + lines = [] + for i, (code, desc, uom, qty, val, disc, rate) in enumerate(raw_lines, start=1): + net_price = _round(val * (1 - disc / 100)) + taxable = _round(net_price * qty) + tax = _round(taxable * rate / 100) + lines.append(InvoiceLine(line_no=i, item_code=code, description=desc, uom=uom, + quantity=qty, unit_value=val, discount_pct=disc, + net_price=net_price, taxable_amount=taxable, + tax_rate=rate, tax_amount=tax)) + return lines + + def _finalize_invoice(self, inv: Invoice) -> Invoice: + inv.taxable_total = _round(sum(l.taxable_amount for l in inv.lines)) + inv.tax_total = _round(sum(l.tax_amount for l in inv.lines)) + inv.total = _round(inv.taxable_total + inv.tax_total) + return inv + + def _init_invoices(self) -> List[Invoice]: + now = datetime.now() + invoices = [ + Invoice(id=1, doc_type=DocumentType.IMMEDIATE_INVOICE, doc_no="FT-2024-0001", + doc_date=now - timedelta(days=6), posting_date=now - timedelta(days=6), + customer_id=1, customer_name="Acme Corporation S.p.A.", + customer_vat="IT01234567890", your_reference="ODA 4521", + payment_terms="RB 30 gg FM", salesperson="Rossi M.", + lines=self._build_invoice_lines([ + ("PROD001", "Industrial Widget A", "NR", 10, 125.50, 0, 22.0), + ("PROD004", "Precision Bearing Set", "NR", 5, 89.99, 5, 22.0), + ]), + status=DocumentStatus.ISSUED, printed=True, issued=True, + posted_to_inventory=True), + Invoice(id=2, doc_type=DocumentType.DELIVERY_NOTE, doc_no="DDT-2024-0014", + doc_date=now - timedelta(days=3), posting_date=now - timedelta(days=3), + customer_id=2, customer_name="TechFlow Industries GmbH", + customer_vat="DE811234567", payment_terms="Bonifico 60 gg", + salesperson="Bianchi L.", + lines=self._build_invoice_lines([ + ("PROD005", "Control Module MCU-32", "NR", 8, 234.50, 0, 22.0), + ]), + status=DocumentStatus.PRINTED, printed=True), + Invoice(id=3, doc_type=DocumentType.IMMEDIATE_INVOICE, doc_no="FT-2024-0002", + doc_date=now - timedelta(days=1), posting_date=now - timedelta(days=1), + customer_id=3, customer_name="European Logistics SARL", + customer_vat="FR40123456789", payment_terms="Bonifico 30 gg", + salesperson="Rossi M.", + lines=self._build_invoice_lines([ + ("PROD002", "Premium Connector Set", "NR", 20, 45.00, 10, 22.0), + ("PROD003", "Steel Bracket Type B", "KG", 15, 22.75, 0, 22.0), + ]), + status=DocumentStatus.DRAFT), + Invoice(id=4, doc_type=DocumentType.CREDIT_NOTE, doc_no="NC-2024-0003", + doc_date=now - timedelta(days=2), posting_date=now - timedelta(days=2), + customer_id=1, customer_name="Acme Corporation S.p.A.", + customer_vat="IT01234567890", payment_terms="RB 30 gg FM", + salesperson="Rossi M.", notes="Reso merce difettosa", + lines=self._build_invoice_lines([ + ("PROD004", "Precision Bearing Set", "NR", 2, 89.99, 0, 22.0), + ]), + status=DocumentStatus.ISSUED, printed=True, issued=True), + ] + return [self._finalize_invoice(i) for i in invoices] + + # ---------- Seed: Scadenzario (partite aperte) ---------- + def _init_payment_schedules(self) -> List[PaymentSchedule]: + t = _today() + R, P = PaymentScheduleType.RECEIVABLE, PaymentScheduleType.PAYABLE + return [ + # --- Incassi (receivables) da fatture clienti --- + PaymentSchedule(id=1, type=R, custsupp_name="Acme Corporation S.p.A.", + doc_no="FT-2024-0001", doc_date=t - timedelta(days=36), + due_date=t - timedelta(days=6), payment_method="RB", + amount=2052.59, bank="Intesa Sanpaolo", closed=False, + notes="Scaduta — da sollecitare"), + PaymentSchedule(id=2, type=R, custsupp_name="European Logistics SARL", + doc_no="FT-2024-0002", doc_date=t - timedelta(days=1), + due_date=t + timedelta(days=29), payment_method="Bonifico", + amount=1404.53, bank="Intesa Sanpaolo", closed=False), + PaymentSchedule(id=3, type=R, custsupp_name="TechFlow Industries GmbH", + doc_no="FT-2024-0009", doc_date=t - timedelta(days=50), + due_date=t + timedelta(days=4), payment_method="RID", + amount=3680.00, bank="Unicredit", closed=False), + PaymentSchedule(id=4, type=R, custsupp_name="Acme Corporation S.p.A.", + doc_no="NC-2024-0003", doc_date=t - timedelta(days=2), + due_date=t + timedelta(days=10), payment_method="RB", + amount=219.58, is_credit_note=True, closed=False, + notes="Nota di credito — riduce il credito"), + PaymentSchedule(id=5, type=R, custsupp_name="Alpine Manufacturing AG", + doc_no="FT-2024-0007", doc_date=t - timedelta(days=40), + due_date=t - timedelta(days=10), payment_method="Bonifico", + amount=5430.00, bank="Unicredit", closed=False, + notes="Scaduta da 10 giorni"), + PaymentSchedule(id=6, type=R, custsupp_name="European Logistics SARL", + doc_no="FT-2024-0005", doc_date=t - timedelta(days=60), + due_date=t - timedelta(days=30), payment_method="RB", + amount=2100.00, bank="Intesa Sanpaolo", closed=True, + notes="Incassata"), + # --- Pagamenti (payables) verso fornitori --- + PaymentSchedule(id=7, type=P, custsupp_name="Global Components Ltd", + doc_no="FTACQ-1187", doc_date=t - timedelta(days=33), + due_date=t - timedelta(days=3), payment_method="Bonifico", + amount=8500.00, closed=False, notes="Scaduta — pagamento urgente"), + PaymentSchedule(id=8, type=P, custsupp_name="Asia Electronics Co.", + doc_no="FTACQ-2204", doc_date=t - timedelta(days=12), + due_date=t + timedelta(days=2), payment_method="Bonifico", + amount=8000.00, closed=False), + PaymentSchedule(id=9, type=P, custsupp_name="Nordic Materials AB", + doc_no="FTACQ-0991", doc_date=t - timedelta(days=5), + due_date=t + timedelta(days=25), payment_method="Bonifico", + amount=2900.00, closed=False), + PaymentSchedule(id=10, type=P, custsupp_name="Global Components Ltd", + doc_no="FTACQ-1150", doc_date=t - timedelta(days=70), + due_date=t - timedelta(days=40), payment_method="RIBA", + amount=3200.00, closed=True, notes="Pagata"), + ] + + # ==================== CRUD: Anagrafiche ==================== + def get_customers(self): return self.customers + def get_customer(self, cid): return next((c for c in self.customers if c.id == cid), None) def create_customer(self, customer: Customer) -> Customer: customer.id = self.next_customer_id @@ -204,19 +353,16 @@ class FakeDatabase: self.customers.append(customer) return customer - def update_customer(self, customer_id: int, customer_data: dict) -> Optional[Customer]: - customer = self.get_customer(customer_id) - if customer: - for key, value in customer_data.items(): - if hasattr(customer, key) and value is not None: - setattr(customer, key, value) - return customer + def update_customer(self, cid, data: dict): + c = self.get_customer(cid) + if c: + for k, v in data.items(): + if hasattr(c, k) and v is not None: + setattr(c, k, v) + return c - def get_suppliers(self) -> List[Supplier]: - return self.suppliers - - def get_supplier(self, supplier_id: int) -> Optional[Supplier]: - return next((s for s in self.suppliers if s.id == supplier_id), None) + def get_suppliers(self): return self.suppliers + def get_supplier(self, sid): return next((s for s in self.suppliers if s.id == sid), None) def create_supplier(self, supplier: Supplier) -> Supplier: supplier.id = self.next_supplier_id @@ -224,11 +370,9 @@ class FakeDatabase: self.suppliers.append(supplier) return supplier - def get_products(self) -> List[Product]: - return self.products - - def get_product(self, product_id: int) -> Optional[Product]: - return next((p for p in self.products if p.id == product_id), None) + def get_products(self): return self.products + def get_product(self, pid): return next((p for p in self.products if p.id == pid), None) + def get_product_by_code(self, code): return next((p for p in self.products if p.code == code), None) def create_product(self, product: Product) -> Product: product.id = self.next_product_id @@ -236,11 +380,9 @@ class FakeDatabase: self.products.append(product) return product - def get_sales_orders(self) -> List[SalesOrder]: - return self.sales_orders - - def get_sales_order(self, order_id: int) -> Optional[SalesOrder]: - return next((o for o in self.sales_orders if o.id == order_id), None) + # ==================== CRUD: Ordini ==================== + def get_sales_orders(self): return self.sales_orders + def get_sales_order(self, oid): return next((o for o in self.sales_orders if o.id == oid), None) def create_sales_order(self, order: SalesOrder) -> SalesOrder: order.id = self.next_so_id @@ -248,11 +390,8 @@ class FakeDatabase: self.sales_orders.append(order) return order - def get_purchase_orders(self) -> List[PurchaseOrder]: - return self.purchase_orders - - def get_purchase_order(self, order_id: int) -> Optional[PurchaseOrder]: - return next((o for o in self.purchase_orders if o.id == order_id), None) + def get_purchase_orders(self): return self.purchase_orders + def get_purchase_order(self, oid): return next((o for o in self.purchase_orders if o.id == oid), None) def create_purchase_order(self, order: PurchaseOrder) -> PurchaseOrder: order.id = self.next_po_id @@ -260,16 +399,173 @@ class FakeDatabase: self.purchase_orders.append(order) return order - # Dashboard statistics - def get_dashboard_stats(self): - sales_to_fulfill = sum(1 for o in self.sales_orders if o.status in [OrderStatus.CONFIRMED, OrderStatus.DRAFT]) - purchase_to_receive = sum(1 for o in self.purchase_orders if o.status in [OrderStatus.CONFIRMED, OrderStatus.DRAFT, OrderStatus.PARTIALLY_RECEIVED]) + # ==================== CRUD: Fatture ==================== + def get_invoices(self): return self.invoices + def get_invoice(self, iid): return next((i for i in self.invoices if i.id == iid), None) + def get_invoices_by_type(self, doc_type: DocumentType): + return [i for i in self.invoices if i.doc_type == doc_type] + + def create_invoice(self, invoice: Invoice) -> Invoice: + invoice.id = self.next_invoice_id + self.next_invoice_id += 1 + self._finalize_invoice(invoice) + self.invoices.append(invoice) + return invoice + + # ==================== CRUD: Magazzino ==================== + def get_inventory_reasons(self): return self.inventory_reasons + def get_inventory_reason(self, code): return next((r for r in self.inventory_reasons if r.code == code), None) + + def get_inventory_movements(self): return self.inventory_movements + def get_inventory_movement(self, mid): return next((m for m in self.inventory_movements if m.id == mid), None) + + def create_inventory_movement(self, movement: InventoryMovement) -> InventoryMovement: + movement.id = self.next_movement_id + self.next_movement_id += 1 + movement.total_amount = _round(sum(l.line_amount for l in movement.lines)) + self.inventory_movements.append(movement) + if movement.posted: + self._apply_movement_to_balances(movement) + return movement + + def post_movement(self, mid) -> Optional[InventoryMovement]: + """Conferma un movimento e aggiorna le giacenze (Mago4: PostedToInventory).""" + m = self.get_inventory_movement(mid) + if m and not m.posted: + m.posted = True + self._apply_movement_to_balances(m) + return m + + def _apply_movement_to_balances(self, movement: InventoryMovement): + """Applica il movimento alle giacenze articoli secondo il segno della causale.""" + delta_sign = 1 if movement.sign == MovementSign.LOAD else -1 + for line in movement.lines: + product = self.get_product_by_code(line.item_code) + if product: + product.quantity_in_stock += int(delta_sign * line.quantity) + + # ==================== Scadenzario (AP_AR) ==================== + def get_payment_schedules(self): return self.payment_schedules + def get_payment_schedule(self, sid): return next((s for s in self.payment_schedules if s.id == sid), None) + + def schedule_status(self, s: PaymentSchedule) -> str: + """Stato derivato: paid / overdue / due_soon / open""" + if s.closed: + return "paid" + days = (s.due_date - _today()).days + if days < 0: + return "overdue" + if days <= 7: + return "due_soon" + return "open" + + def schedule_days_to_due(self, s: PaymentSchedule) -> int: + return (s.due_date - _today()).days + + def settle_schedule(self, sid) -> Optional[PaymentSchedule]: + """Salda/incassa una partita (Mago4: Closed/Collected).""" + s = self.get_payment_schedule(sid) + if s and not s.closed: + s.closed = True + return s + + def get_open_schedules(self, stype: Optional[PaymentScheduleType] = None): + items = [s for s in self.payment_schedules if not s.closed] + if stype: + items = [s for s in items if s.type == stype] + return items + + def get_overdue_schedules(self, stype: Optional[PaymentScheduleType] = None): + return [s for s in self.get_open_schedules(stype) if self.schedule_status(s) == "overdue"] + + def get_upcoming_schedules(self, days: int = 30): + """Partite aperte in scadenza nei prossimi 'days' giorni (incluse scadute).""" + limit = _today() + timedelta(days=days) + items = [s for s in self.get_open_schedules() if s.due_date <= limit] + return sorted(items, key=lambda s: s.due_date) + + def get_credit_exceeded_customers(self): + """Clienti che hanno superato il limite di fido (modulo CreditLimit).""" + return [c for c in self.customers if c.credit_limit > 0 and c.balance > c.credit_limit] + + def get_todo_items(self): + """Aggrega le 'cose da fare' per la dashboard.""" + draft_invoices = [i for i in self.invoices if i.status == DocumentStatus.DRAFT] + draft_movements = [m for m in self.inventory_movements if not m.posted] + orders_to_fulfill = [o for o in self.sales_orders + if o.status in (OrderStatus.CONFIRMED, OrderStatus.DRAFT)] + low_stock = [p for p in self.products if p.quantity_in_stock <= p.reorder_level] + overdue_recv = self.get_overdue_schedules(PaymentScheduleType.RECEIVABLE) + overdue_pay = self.get_overdue_schedules(PaymentScheduleType.PAYABLE) + credit_exceeded = self.get_credit_exceeded_customers() + + todos = [] + if overdue_pay: + todos.append({"icon": "payments", "color": "red", "priority": 1, + "text": f"{len(overdue_pay)} pagamenti scaduti da effettuare", + "amount": sum(s.amount for s in overdue_pay), + "link": "/scadenzario?type=payable", "action": "Paga"}) + if overdue_recv: + todos.append({"icon": "request_quote", "color": "orange", "priority": 2, + "text": f"{len(overdue_recv)} incassi scaduti da sollecitare", + "amount": sum(s.amount for s in overdue_recv), + "link": "/scadenzario?type=receivable", "action": "Sollecita"}) + if low_stock: + todos.append({"icon": "production_quantity_limits", "color": "red", "priority": 3, + "text": f"{len(low_stock)} articoli sotto scorta da ordinare", + "amount": None, "link": "/warehouse", "action": "Ordina"}) + if draft_invoices: + todos.append({"icon": "edit_document", "color": "blue", "priority": 4, + "text": f"{len(draft_invoices)} documenti in bozza da confermare", + "amount": None, "link": "/invoices", "action": "Apri"}) + if orders_to_fulfill: + todos.append({"icon": "local_shipping", "color": "blue", "priority": 5, + "text": f"{len(orders_to_fulfill)} ordini cliente da evadere", + "amount": None, "link": "/sales-orders", "action": "Apri"}) + if draft_movements: + todos.append({"icon": "swap_vert", "color": "teal", "priority": 6, + "text": f"{len(draft_movements)} movimenti di magazzino da confermare", + "amount": None, "link": "/inventory-movements", "action": "Apri"}) + if credit_exceeded: + todos.append({"icon": "credit_card_off", "color": "red", "priority": 7, + "text": f"{len(credit_exceeded)} clienti con fido superato", + "amount": None, "link": "/customers", "action": "Verifica"}) + return sorted(todos, key=lambda x: x["priority"]) + + def get_cashflow_forecast(self, days: int = 30): + """Previsione di cassa a 'days' giorni: incassi vs pagamenti previsti.""" + upcoming = self.get_upcoming_schedules(days) + inflow = sum(s.signed_amount for s in upcoming if s.type == PaymentScheduleType.RECEIVABLE) + outflow = sum(s.amount for s in upcoming if s.type == PaymentScheduleType.PAYABLE) + return {"days": days, "inflow": _round(inflow), "outflow": _round(outflow), + "net": _round(inflow - outflow)} + + # ==================== Statistiche ==================== + def get_dashboard_stats(self): + sales_to_fulfill = sum(1 for o in self.sales_orders + if o.status in (OrderStatus.CONFIRMED, OrderStatus.DRAFT)) + purchase_to_receive = sum(1 for o in self.purchase_orders + if o.status in (OrderStatus.CONFIRMED, OrderStatus.DRAFT, + OrderStatus.PARTIALLY_RECEIVED)) total_receivable = sum(c.balance for c in self.customers) total_payable = sum(s.balance for s in self.suppliers) - low_stock_items = [p for p in self.products if p.quantity_in_stock <= p.reorder_level] + # Fatturato emesso (fatture/note credito issued) + invoiced_total = sum( + (i.total if i.doc_type != DocumentType.CREDIT_NOTE else -i.total) + for i in self.invoices if i.issued + ) + pending_invoices = sum(1 for i in self.invoices if i.status == DocumentStatus.DRAFT) + warehouse_value = sum(p.quantity_in_stock * p.purchase_price for p in self.products) + + # Scadenzario + overdue_recv = self.get_overdue_schedules(PaymentScheduleType.RECEIVABLE) + overdue_pay = self.get_overdue_schedules(PaymentScheduleType.PAYABLE) + open_recv = self.get_open_schedules(PaymentScheduleType.RECEIVABLE) + open_pay = self.get_open_schedules(PaymentScheduleType.PAYABLE) + return { "sales_to_fulfill": sales_to_fulfill, "purchase_to_receive": purchase_to_receive, @@ -280,6 +576,19 @@ class FakeDatabase: "total_customers": len(self.customers), "total_suppliers": len(self.suppliers), "total_products": len(self.products), + "invoiced_total": invoiced_total, + "pending_invoices": pending_invoices, + "warehouse_value": warehouse_value, + "total_invoices": len(self.invoices), + "total_movements": len(self.inventory_movements), + # Scadenze + "overdue_recv_count": len(overdue_recv), + "overdue_recv_amount": _round(sum(s.amount for s in overdue_recv)), + "overdue_pay_count": len(overdue_pay), + "overdue_pay_amount": _round(sum(s.amount for s in overdue_pay)), + "open_recv_amount": _round(sum(s.signed_amount for s in open_recv)), + "open_pay_amount": _round(sum(s.amount for s in open_pay)), + "credit_exceeded_count": len(self.get_credit_exceeded_customers()), } diff --git a/main.py b/main.py index 245fb59..989aa90 100644 --- a/main.py +++ b/main.py @@ -8,24 +8,16 @@ from datetime import datetime import os from models import ( - Customer, Supplier, Product, SalesOrder, PurchaseOrder, - OrderLine, OrderStatus, Dashboard, DashboardWidget + Customer, Supplier, Product, SalesOrder, PurchaseOrder, OrderStatus, + Invoice, InventoryMovement, DocumentType, DocumentStatus, + PaymentSchedule, PaymentScheduleType, + DOCUMENT_TYPE_LABELS, DOCUMENT_STATUS_LABELS, PAYMENT_SCHEDULE_TYPE_LABELS, MovementSign, ) from database import db -app = FastAPI( - title="Mago4 Demo", - description="Demo gestionale ERP con Python FastAPI", - version="1.0.0" -) +app = FastAPI(title="Mago4 Demo", description="Demo gestionale ERP — Python FastAPI", version="2.0.0") -# Setup static e template files BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -os.makedirs(os.path.join(BASE_DIR, "static"), exist_ok=True) -os.makedirs(os.path.join(BASE_DIR, "static", "css"), exist_ok=True) -os.makedirs(os.path.join(BASE_DIR, "static", "js"), exist_ok=True) -os.makedirs(os.path.join(BASE_DIR, "templates"), exist_ok=True) - app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static") # Workaround Python 3.14 + Jinja2: disable template cache (globals dict not hashable as cache key) @@ -34,268 +26,295 @@ _jinja_env = Environment( autoescape=True, cache_size=0, ) +# Helper esposti ai template +_jinja_env.globals["doc_type_label"] = lambda t: DOCUMENT_TYPE_LABELS.get(t, str(t)) +_jinja_env.globals["doc_status_label"] = lambda s: DOCUMENT_STATUS_LABELS.get(s, str(s)) +_jinja_env.globals["sched_type_label"] = lambda t: PAYMENT_SCHEDULE_TYPE_LABELS.get(t, str(t)) +_jinja_env.globals["sched_status"] = db.schedule_status +_jinja_env.globals["sched_days"] = db.schedule_days_to_due templates = Jinja2Templates(env=_jinja_env) -# ==================== DASHBOARD ==================== +def ctx(request: Request, active_module: str, **extra): + base = { + "request": request, + "active_module": active_module, + "operation_date": datetime.now().strftime("%d/%m/%Y"), + } + base.update(extra) + return base + + +def render(request: Request, name: str, active_module: str, **extra): + return templates.TemplateResponse(request, name, ctx(request, active_module, **extra)) + + +# ============================================================ +# DASHBOARD +# ============================================================ @app.get("/", response_class=HTMLResponse) async def dashboard(request: Request): stats = db.get_dashboard_stats() - - widgets = [ - [ - DashboardWidget( - title="Ordini Vendita da Evadere", - style="stats", - size="small", - icon="shopping_cart", - color="blue", - value=str(stats["sales_to_fulfill"]) - ), - DashboardWidget( - title="Ordini Acquisto da Ricevere", - style="stats", - size="small", - icon="local_shipping", - color="orange", - value=str(stats["purchase_to_receive"]) - ), - DashboardWidget( - title="Crediti Clienti", - style="stats", - size="small", - icon="euro_symbol", - color="green", - value=f"€ {stats['total_receivable']:,.2f}", - format="money" - ), - DashboardWidget( - title="Articoli Sotto Soglia", - style="stats", - size="small", - icon="warning", - color="red", - value=str(stats["low_stock_count"]) - ), - ], - [ - DashboardWidget( - title="Ultimi Ordini Vendita", - subtitle="per data ordine", - style="grid", - size="medium", - data={"orders": [ - { - "order_number": o.order_number, - "order_date": o.order_date.strftime("%d/%m/%Y"), - "customer_name": o.customer_name, - "total_amount": f"€ {o.total_amount:,.2f}", - "status": o.status.value - } - for o in sorted(db.get_sales_orders(), key=lambda x: x.order_date, reverse=True)[:5] - ]} - ), - DashboardWidget( - title="Ultimi Ordini Acquisto", - subtitle="per data ordine", - style="grid", - size="medium", - data={"orders": [ - { - "order_number": o.order_number, - "order_date": o.order_date.strftime("%d/%m/%Y"), - "supplier_name": o.supplier_name, - "total_amount": f"€ {o.total_amount:,.2f}", - "status": o.status.value - } - for o in sorted(db.get_purchase_orders(), key=lambda x: x.order_date, reverse=True)[:5] - ]} - ), - ] - ] - - return templates.TemplateResponse(request, "dashboard.html", { - "widgets": widgets, - "stats": stats - }) + upcoming = db.get_upcoming_schedules(30) + todos = db.get_todo_items() + cashflow = db.get_cashflow_forecast(30) + credit_exceeded = db.get_credit_exceeded_customers() + last_invoices = sorted(db.get_invoices(), key=lambda x: x.doc_date, reverse=True)[:5] + return render(request, "dashboard.html", "dashboard", + stats=stats, upcoming=upcoming, todos=todos, cashflow=cashflow, + credit_exceeded=credit_exceeded, last_invoices=last_invoices, + RECEIVABLE=PaymentScheduleType.RECEIVABLE, PAYABLE=PaymentScheduleType.PAYABLE) -# ==================== CUSTOMERS ==================== -@app.get("/api/customers", tags=["Customers"]) -async def list_customers(): - return db.get_customers() - - -@app.get("/api/customers/{customer_id}", tags=["Customers"]) -async def get_customer(customer_id: int): - customer = db.get_customer(customer_id) - if not customer: - raise HTTPException(status_code=404, detail="Cliente non trovato") - return customer - - -@app.post("/api/customers", tags=["Customers"]) -async def create_customer(customer: Customer): - return db.create_customer(customer) - - -@app.put("/api/customers/{customer_id}", tags=["Customers"]) -async def update_customer(customer_id: int, customer_data: dict): - result = db.update_customer(customer_id, customer_data) - if not result: - raise HTTPException(status_code=404, detail="Cliente non trovato") - return result +# ============================================================ +# ANAGRAFICHE +# ============================================================ +@app.get("/anagrafiche", response_class=HTMLResponse) +async def anagrafiche(request: Request): + return render(request, "anagrafiche.html", "anagrafiche") @app.get("/customers", response_class=HTMLResponse) async def customers_page(request: Request): - return templates.TemplateResponse(request, "customers.html", { - "customers": db.get_customers() - }) - - -# ==================== SUPPLIERS ==================== -@app.get("/api/suppliers", tags=["Suppliers"]) -async def list_suppliers(): - return db.get_suppliers() - - -@app.get("/api/suppliers/{supplier_id}", tags=["Suppliers"]) -async def get_supplier(supplier_id: int): - supplier = db.get_supplier(supplier_id) - if not supplier: - raise HTTPException(status_code=404, detail="Fornitore non trovato") - return supplier - - -@app.post("/api/suppliers", tags=["Suppliers"]) -async def create_supplier(supplier: Supplier): - return db.create_supplier(supplier) + return render(request, "customers.html", "anagrafiche", customers=db.get_customers()) @app.get("/suppliers", response_class=HTMLResponse) async def suppliers_page(request: Request): - return templates.TemplateResponse(request, "suppliers.html", { - "suppliers": db.get_suppliers() - }) - - -# ==================== PRODUCTS ==================== -@app.get("/api/products", tags=["Products"]) -async def list_products(): - return db.get_products() - - -@app.get("/api/products/{product_id}", tags=["Products"]) -async def get_product(product_id: int): - product = db.get_product(product_id) - if not product: - raise HTTPException(status_code=404, detail="Articolo non trovato") - return product - - -@app.post("/api/products", tags=["Products"]) -async def create_product(product: Product): - return db.create_product(product) + return render(request, "suppliers.html", "anagrafiche", suppliers=db.get_suppliers()) @app.get("/products", response_class=HTMLResponse) async def products_page(request: Request): products = db.get_products() - return templates.TemplateResponse(request, "products.html", { - "products": products, - "low_stock": [p for p in products if p.quantity_in_stock <= p.reorder_level] - }) + return render(request, "products.html", "anagrafiche", products=products, + low_stock=[p for p in products if p.quantity_in_stock <= p.reorder_level]) -# ==================== SALES ORDERS ==================== -@app.get("/api/sales-orders", tags=["Sales Orders"]) -async def list_sales_orders(): - return db.get_sales_orders() - - -@app.get("/api/sales-orders/{order_id}", tags=["Sales Orders"]) -async def get_sales_order(order_id: int): - order = db.get_sales_order(order_id) - if not order: - raise HTTPException(status_code=404, detail="Ordine vendita non trovato") - return order - - -@app.post("/api/sales-orders", tags=["Sales Orders"]) -async def create_sales_order(order: SalesOrder): - return db.create_sales_order(order) +# ============================================================ +# VENDITE +# ============================================================ +@app.get("/vendite", response_class=HTMLResponse) +async def vendite(request: Request): + return render(request, "vendite.html", "vendite") @app.get("/sales-orders", response_class=HTMLResponse) async def sales_orders_page(request: Request): orders = db.get_sales_orders() - return templates.TemplateResponse(request, "sales_orders.html", { - "orders": orders, - "pending": [o for o in orders if o.status != OrderStatus.RECEIVED] - }) + return render(request, "sales_orders.html", "vendite", orders=orders, + pending=[o for o in orders if o.status != OrderStatus.RECEIVED]) -# ==================== PURCHASE ORDERS ==================== -@app.get("/api/purchase-orders", tags=["Purchase Orders"]) -async def list_purchase_orders(): - return db.get_purchase_orders() +@app.get("/invoices", response_class=HTMLResponse) +async def invoices_page(request: Request, type: str = "all"): + invoices = db.get_invoices() + selected = "all" + if type != "all": + try: + dt = DocumentType(type) + invoices = [i for i in invoices if i.doc_type == dt] + selected = type + except ValueError: + pass + invoices = sorted(invoices, key=lambda x: x.doc_date, reverse=True) + return render(request, "invoices.html", "vendite", invoices=invoices, + selected_type=selected, doc_types=list(DocumentType)) -@app.get("/api/purchase-orders/{order_id}", tags=["Purchase Orders"]) -async def get_purchase_order(order_id: int): - order = db.get_purchase_order(order_id) - if not order: - raise HTTPException(status_code=404, detail="Ordine acquisto non trovato") - return order +@app.get("/invoices/{invoice_id}", response_class=HTMLResponse) +async def invoice_detail_page(request: Request, invoice_id: int): + invoice = db.get_invoice(invoice_id) + if not invoice: + raise HTTPException(status_code=404, detail="Documento non trovato") + return render(request, "invoice_detail.html", "vendite", invoice=invoice) -@app.post("/api/purchase-orders", tags=["Purchase Orders"]) -async def create_purchase_order(order: PurchaseOrder): - return db.create_purchase_order(order) +# ============================================================ +# ACQUISTI +# ============================================================ +@app.get("/acquisti", response_class=HTMLResponse) +async def acquisti(request: Request): + return render(request, "acquisti.html", "acquisti") @app.get("/purchase-orders", response_class=HTMLResponse) async def purchase_orders_page(request: Request): orders = db.get_purchase_orders() - return templates.TemplateResponse(request, "purchase_orders.html", { - "orders": orders, - "pending": [o for o in orders if o.status != OrderStatus.RECEIVED] - }) + return render(request, "purchase_orders.html", "acquisti", orders=orders, + pending=[o for o in orders if o.status != OrderStatus.RECEIVED]) -# ==================== WAREHOUSE ==================== -@app.get("/api/warehouse/stats", tags=["Warehouse"]) -async def warehouse_stats(): - stats = db.get_dashboard_stats() - return { - "total_products": stats["total_products"], - "low_stock_count": stats["low_stock_count"], - "low_stock_items": stats["low_stock_items"], - "all_products": db.get_products() - } +# ============================================================ +# AMMINISTRAZIONE / SCADENZARIO (AP_AR) +# ============================================================ +@app.get("/amministrazione", response_class=HTMLResponse) +async def amministrazione(request: Request): + return render(request, "amministrazione.html", "amministrazione") + + +@app.get("/scadenzario", response_class=HTMLResponse) +async def scadenzario_page(request: Request, type: str = "all"): + schedules = db.get_payment_schedules() + selected = "all" + if type in ("receivable", "payable"): + st = PaymentScheduleType(type) + schedules = [s for s in schedules if s.type == st] + selected = type + # ordina: prima le aperte per data scadenza, poi le chiuse + schedules = sorted(schedules, key=lambda s: (s.closed, s.due_date)) + return render(request, "scadenzario.html", "amministrazione", + schedules=schedules, selected_type=selected, + RECEIVABLE=PaymentScheduleType.RECEIVABLE, PAYABLE=PaymentScheduleType.PAYABLE) + + +@app.post("/scadenzario/{schedule_id}/settle") +async def settle_schedule_action(schedule_id: int): + s = db.settle_schedule(schedule_id) + if not s: + raise HTTPException(status_code=404, detail="Partita non trovata o già saldata") + return {"status": "ok", "schedule_id": schedule_id, "closed": s.closed} + + +# ============================================================ +# MAGAZZINO / LOGISTICA +# ============================================================ +@app.get("/magazzino", response_class=HTMLResponse) +async def magazzino(request: Request): + return render(request, "magazzino.html", "magazzino") @app.get("/warehouse", response_class=HTMLResponse) async def warehouse_page(request: Request): stats = db.get_dashboard_stats() - return templates.TemplateResponse(request, "warehouse.html", { - "products": db.get_products(), - "low_stock": stats["low_stock_items"] - }) + return render(request, "warehouse.html", "magazzino", products=db.get_products(), + low_stock=stats["low_stock_items"], warehouse_value=stats["warehouse_value"]) -# ==================== INFO ==================== +@app.get("/inventory-movements", response_class=HTMLResponse) +async def inventory_movements_page(request: Request): + movements = sorted(db.get_inventory_movements(), key=lambda m: m.posting_date, reverse=True) + return render(request, "inventory_movements.html", "magazzino", movements=movements) + + +@app.get("/inventory-movements/{movement_id}", response_class=HTMLResponse) +async def movement_detail_page(request: Request, movement_id: int): + movement = db.get_inventory_movement(movement_id) + if not movement: + raise HTTPException(status_code=404, detail="Movimento non trovato") + return render(request, "movement_detail.html", "magazzino", movement=movement) + + +@app.post("/inventory-movements/{movement_id}/post") +async def post_movement_action(movement_id: int): + m = db.post_movement(movement_id) + if not m: + raise HTTPException(status_code=404, detail="Movimento non trovato o già confermato") + return {"status": "ok", "movement_id": movement_id, "posted": m.posted} + + +@app.get("/inventory-reasons", response_class=HTMLResponse) +async def inventory_reasons_page(request: Request): + return render(request, "inventory_reasons.html", "magazzino", reasons=db.get_inventory_reasons()) + + +# ============================================================ +# API REST +# ============================================================ +@app.get("/api/customers", tags=["Customers"]) +async def api_customers(): return db.get_customers() + +@app.get("/api/customers/{cid}", tags=["Customers"]) +async def api_customer(cid: int): + c = db.get_customer(cid) + if not c: raise HTTPException(404, "Cliente non trovato") + return c + +@app.post("/api/customers", tags=["Customers"]) +async def api_create_customer(customer: Customer): return db.create_customer(customer) + +@app.get("/api/suppliers", tags=["Suppliers"]) +async def api_suppliers(): return db.get_suppliers() + +@app.get("/api/suppliers/{sid}", tags=["Suppliers"]) +async def api_supplier(sid: int): + s = db.get_supplier(sid) + if not s: raise HTTPException(404, "Fornitore non trovato") + return s + +@app.get("/api/products", tags=["Products"]) +async def api_products(): return db.get_products() + +@app.get("/api/products/{pid}", tags=["Products"]) +async def api_product(pid: int): + p = db.get_product(pid) + if not p: raise HTTPException(404, "Articolo non trovato") + return p + +@app.get("/api/sales-orders", tags=["Sales Orders"]) +async def api_sales_orders(): return db.get_sales_orders() + +@app.get("/api/sales-orders/{oid}", tags=["Sales Orders"]) +async def api_sales_order(oid: int): + o = db.get_sales_order(oid) + if not o: raise HTTPException(404, "Ordine non trovato") + return o + +@app.get("/api/purchase-orders", tags=["Purchase Orders"]) +async def api_purchase_orders(): return db.get_purchase_orders() + +@app.get("/api/purchase-orders/{oid}", tags=["Purchase Orders"]) +async def api_purchase_order(oid: int): + o = db.get_purchase_order(oid) + if not o: raise HTTPException(404, "Ordine non trovato") + return o + +@app.get("/api/invoices", tags=["Invoices"]) +async def api_invoices(): return db.get_invoices() + +@app.get("/api/invoices/{iid}", tags=["Invoices"]) +async def api_invoice(iid: int): + i = db.get_invoice(iid) + if not i: raise HTTPException(404, "Documento non trovato") + return i + +@app.post("/api/invoices", tags=["Invoices"]) +async def api_create_invoice(invoice: Invoice): return db.create_invoice(invoice) + +@app.get("/api/inventory-movements", tags=["Warehouse"]) +async def api_movements(): return db.get_inventory_movements() + +@app.get("/api/inventory-movements/{mid}", tags=["Warehouse"]) +async def api_movement(mid: int): + m = db.get_inventory_movement(mid) + if not m: raise HTTPException(404, "Movimento non trovato") + return m + +@app.post("/api/inventory-movements", tags=["Warehouse"]) +async def api_create_movement(movement: InventoryMovement): return db.create_inventory_movement(movement) + +@app.get("/api/inventory-reasons", tags=["Warehouse"]) +async def api_reasons(): return db.get_inventory_reasons() + +@app.get("/api/payment-schedules", tags=["Scadenzario"]) +async def api_schedules(): return db.get_payment_schedules() + +@app.get("/api/payment-schedules/{sid}", tags=["Scadenzario"]) +async def api_schedule(sid: int): + s = db.get_payment_schedule(sid) + if not s: raise HTTPException(404, "Partita non trovata") + return s + +@app.get("/api/dashboard/todos", tags=["Dashboard"]) +async def api_todos(): return db.get_todo_items() + +@app.get("/api/dashboard/cashflow", tags=["Dashboard"]) +async def api_cashflow(days: int = 30): return db.get_cashflow_forecast(days) + @app.get("/api/info", tags=["Info"]) async def api_info(): - stats = db.get_dashboard_stats() - return { - "name": "Mago4 Demo", - "version": "1.0.0", - "status": "active", - "database_type": "in-memory", - "statistics": stats - } + return {"name": "Mago4 Demo", "version": "2.0.0", "status": "active", + "database_type": "in-memory", "statistics": db.get_dashboard_stats()} if __name__ == "__main__": diff --git a/models.py b/models.py index bc9b96b..6d1cb89 100644 --- a/models.py +++ b/models.py @@ -4,6 +4,7 @@ from datetime import datetime from enum import Enum +# ==================== ENUMS ==================== class OrderStatus(str, Enum): DRAFT = "draft" CONFIRMED = "confirmed" @@ -17,6 +18,51 @@ class OrderType(str, Enum): PURCHASE = "purchase" +class DocumentType(str, Enum): + """Tipi documento di vendita (Mago4: MA_SaleDoc.DocumentType)""" + IMMEDIATE_INVOICE = "immediate_invoice" # Fattura Immediata + DELIVERY_NOTE = "delivery_note" # Documento di Trasporto (DDT) + ACCOMPANYING_INVOICE = "accompanying_invoice" # Fattura Accompagnatoria + PROFORMA_INVOICE = "proforma_invoice" # Fattura ProForma + CREDIT_NOTE = "credit_note" # Nota di Credito + DEBIT_NOTE = "debit_note" # Nota di Debito + + +DOCUMENT_TYPE_LABELS = { + DocumentType.IMMEDIATE_INVOICE: "Fattura Immediata", + DocumentType.DELIVERY_NOTE: "Documento di Trasporto", + DocumentType.ACCOMPANYING_INVOICE: "Fattura Accompagnatoria", + DocumentType.PROFORMA_INVOICE: "Fattura ProForma", + DocumentType.CREDIT_NOTE: "Nota di Credito", + DocumentType.DEBIT_NOTE: "Nota di Debito", +} + + +class DocumentStatus(str, Enum): + """Stato documento (Mago4: Printed / Issued / PostedToInventory)""" + DRAFT = "draft" # Bozza + CONFIRMED = "confirmed" # Confermato + PRINTED = "printed" # Stampato + ISSUED = "issued" # Emesso (definitivo) + POSTED = "posted" # Contabilizzato + + +DOCUMENT_STATUS_LABELS = { + DocumentStatus.DRAFT: "Bozza", + DocumentStatus.CONFIRMED: "Confermato", + DocumentStatus.PRINTED: "Stampato", + DocumentStatus.ISSUED: "Emesso", + DocumentStatus.POSTED: "Contabilizzato", +} + + +class MovementSign(str, Enum): + """Segno movimento di magazzino (Mago4: InventoryReasons.DebitCreditSign)""" + LOAD = "load" # Carico (+) + UNLOAD = "unload" # Scarico (-) + + +# ==================== ANAGRAFICHE ==================== class Customer(BaseModel): id: int code: str @@ -26,24 +72,11 @@ class Customer(BaseModel): address: str city: str country: str - credit_limit: float - balance: float - - class Config: - json_schema_extra = { - "example": { - "id": 1, - "code": "CUST001", - "name": "Acme Corp", - "email": "info@acme.com", - "phone": "+39 02 1234567", - "address": "Via Roma 10", - "city": "Milano", - "country": "IT", - "credit_limit": 50000, - "balance": 15000 - } - } + vat_number: str = "" # Partita IVA + fiscal_code: str = "" # Codice Fiscale + payment_terms: str = "RB 30 gg" # Pagamento + credit_limit: float = 0.0 + balance: float = 0.0 class Supplier(BaseModel): @@ -55,24 +88,9 @@ class Supplier(BaseModel): address: str city: str country: str - payment_terms_days: int - balance: float - - class Config: - json_schema_extra = { - "example": { - "id": 1, - "code": "SUPP001", - "name": "Global Components Ltd", - "email": "sales@globalcomp.com", - "phone": "+44 20 7946 0958", - "address": "123 Oxford Street", - "city": "London", - "country": "UK", - "payment_terms_days": 30, - "balance": 5000 - } - } + vat_number: str = "" + payment_terms_days: int = 30 + balance: float = 0.0 class Product(BaseModel): @@ -80,26 +98,17 @@ class Product(BaseModel): code: str description: str category: str - unit_price: float - quantity_in_stock: int - reorder_level: int + uom: str = "NR" # Unità di misura + unit_price: float = 0.0 # Prezzo di vendita + purchase_price: float = 0.0 # Prezzo di acquisto / costo + tax_rate: float = 22.0 # Aliquota IVA % + quantity_in_stock: int = 0 # Giacenza (On-hand) + reorder_level: int = 0 # Scorta minima (Min. Stock) + max_stock: int = 0 # Scorta massima supplier_id: Optional[int] = None - class Config: - json_schema_extra = { - "example": { - "id": 1, - "code": "PROD001", - "description": "Industrial Widget A", - "category": "Electronics", - "unit_price": 125.50, - "quantity_in_stock": 250, - "reorder_level": 50, - "supplier_id": 1 - } - } - +# ==================== ORDINI ==================== class OrderLine(BaseModel): id: int product_id: int @@ -109,19 +118,6 @@ class OrderLine(BaseModel): unit_price: float total_amount: float - class Config: - json_schema_extra = { - "example": { - "id": 1, - "product_id": 1, - "product_code": "PROD001", - "product_description": "Industrial Widget A", - "quantity": 10, - "unit_price": 125.50, - "total_amount": 1255.00 - } - } - class SalesOrder(BaseModel): id: int @@ -134,21 +130,6 @@ class SalesOrder(BaseModel): total_amount: float delivery_date: Optional[datetime] = None - class Config: - json_schema_extra = { - "example": { - "id": 1, - "order_number": "SO-2024-001", - "order_date": "2024-01-15T10:30:00", - "customer_id": 1, - "customer_name": "Acme Corp", - "status": "confirmed", - "lines": [], - "total_amount": 2500.00, - "delivery_date": "2024-02-15T00:00:00" - } - } - class PurchaseOrder(BaseModel): id: int @@ -161,33 +142,133 @@ class PurchaseOrder(BaseModel): total_amount: float expected_delivery_date: Optional[datetime] = None - class Config: - json_schema_extra = { - "example": { - "id": 1, - "order_number": "PO-2024-001", - "order_date": "2024-01-10T14:00:00", - "supplier_id": 1, - "supplier_name": "Global Components Ltd", - "status": "confirmed", - "lines": [], - "total_amount": 5000.00, - "expected_delivery_date": "2024-02-10T00:00:00" - } - } + +# ==================== FATTURAZIONE (InvoiceMng) ==================== +class InvoiceLine(BaseModel): + """Riga documento (Mago4: MA_SaleDocDetail)""" + line_no: int + item_code: str + description: str + uom: str = "NR" + quantity: float = 1.0 + unit_value: float = 0.0 # Valore unitario + discount_pct: float = 0.0 # Sconto % + net_price: float = 0.0 # Prezzo netto unitario + taxable_amount: float = 0.0 # Imponibile riga + tax_rate: float = 22.0 # Aliquota IVA + tax_amount: float = 0.0 # Imposta riga +class Invoice(BaseModel): + """Documento di vendita (Mago4: MA_SaleDoc)""" + id: int + doc_type: DocumentType + doc_no: str # Numero documento + doc_date: datetime # Data documento + posting_date: datetime # Data registrazione + customer_id: int + customer_name: str + customer_vat: str = "" + our_reference: str = "" # Nostro riferimento + your_reference: str = "" # Vostro riferimento + payment_terms: str = "RB 30 gg" + price_list: str = "Listino Base" + currency: str = "EUR" + salesperson: str = "" # Agente + net_of_tax: bool = False # Prezzi IVA inclusa + lines: List[InvoiceLine] = [] + taxable_total: float = 0.0 # Totale imponibile + tax_total: float = 0.0 # Totale imposta + total: float = 0.0 # Totale documento + status: DocumentStatus = DocumentStatus.DRAFT + printed: bool = False + issued: bool = False + posted_to_inventory: bool = False + notes: str = "" + + +# ==================== MAGAZZINO (Inventory) ==================== +class InventoryReason(BaseModel): + """Causale di magazzino (Mago4: MA_InventoryReasons)""" + code: str # Causale (es. CAR, SCA) + description: str + sign: MovementSign # Carico / Scarico + fiscal: bool = False # Numerazione fiscale + updates_balance: bool = True # Aggiorna giacenze + + +class InventoryMovementLine(BaseModel): + """Riga movimento (Mago4: MA_InventoryEntriesDetail)""" + line_no: int + item_code: str + description: str + uom: str = "NR" + quantity: float = 0.0 + unit_value: float = 0.0 + line_amount: float = 0.0 + lot: str = "" # Lotto + location: str = "" # Ubicazione + + +class InventoryMovement(BaseModel): + """Movimento di magazzino (Mago4: MA_InventoryEntries)""" + id: int + reason_code: str # Causale + reason_description: str + sign: MovementSign + doc_no: str + doc_date: datetime + posting_date: datetime + storage: str = "MAG01" # Deposito + custsupp_name: str = "" # Cliente/Fornitore + lines: List[InventoryMovementLine] = [] + total_amount: float = 0.0 + posted: bool = False # Movimento confermato (aggiorna giacenze) + notes: str = "" + + +# ==================== SCADENZARIO (AP_AR — Partite/Effetti) ==================== +class PaymentScheduleType(str, Enum): + """Tipo partita (Mago4: DebitCreditSign)""" + RECEIVABLE = "receivable" # Credito / Incasso da ricevere + PAYABLE = "payable" # Debito / Pagamento da effettuare + + +PAYMENT_SCHEDULE_TYPE_LABELS = { + PaymentScheduleType.RECEIVABLE: "Incasso", + PaymentScheduleType.PAYABLE: "Pagamento", +} + + +class PaymentSchedule(BaseModel): + """Partita aperta / scadenza (Mago4: MA_PyblsRcvbls + Details)""" + id: int + type: PaymentScheduleType + custsupp_name: str # Cliente o Fornitore + doc_no: str # Numero documento di origine + doc_date: datetime + due_date: datetime # Data scadenza (InstallmentDate) + installment_no: int = 1 # Numero rata + payment_method: str = "RB" # Modalità (RB, Bonifico, RID...) + amount: float = 0.0 # Importo rata + is_credit_note: bool = False # Nota di credito (segno opposto) + closed: bool = False # Saldata / Incassata (Closed/Collected) + bank: str = "" # Banca di presentazione + notes: str = "" + + @property + def signed_amount(self) -> float: + return -self.amount if self.is_credit_note else self.amount + + +# ==================== UI / DASHBOARD ==================== class DashboardWidget(BaseModel): title: str subtitle: Optional[str] = None - style: str # "stats", "chart", "grid" - size: str = "small" # "small", "medium", "large" + style: str + size: str = "small" icon: Optional[str] = None color: Optional[str] = None value: Optional[str] = None - format: Optional[str] = None # "money", "number", "date" + format: Optional[str] = None data: Optional[dict] = None - - -class Dashboard(BaseModel): - widgets: List[List[DashboardWidget]] diff --git a/static/css/style.css b/static/css/style.css index dcb45cf..88d8dfb 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,671 +1,649 @@ -/* ==================== CSS VARIABLES & COLORS ==================== */ +/* ============================================================ + MAGO4 DEMO — Tema ispirato a Mago4 (Microarea / Zucchetti) + ============================================================ */ :root { - --color-blue: #1976D2; - --color-orange: #F57C00; - --color-green: #388E3C; - --color-red: #D32F2F; - --color-warning: #FBC02D; - --color-grey-50: #FAFAFA; - --color-grey-100: #F5F5F5; - --color-grey-200: #EEEEEE; - --color-grey-300: #E0E0E0; - --color-grey-500: #9E9E9E; - --color-grey-700: #616161; - --color-grey-900: #212121; - --color-bg: #F5F5F5; - --color-bg-dark: #424242; - --color-text: #333333; - --color-text-light: #666666; - --color-border: #E0E0E0; - --color-shadow: rgba(0, 0, 0, 0.1); + /* Palette Mago4 */ + --mago-blue: #1577be; /* Blu header / titoli */ + --mago-blue-dark: #0f5e9c; + --mago-blue-light: #e8f2fb; /* Sfondo voce attiva */ + --mago-green: #5aa632; /* Accento logo */ + --mago-teal: #1ba6b5; - --sidebar-width: 250px; - --topbar-height: 60px; - --spacing-unit: 8px; - --border-radius: 4px; - --transition: 0.2s ease-in-out; + --c-blue: #1976D2; + --c-orange: #F57C00; + --c-green: #388E3C; + --c-red: #D32F2F; + --c-purple: #7B1FA2; + --c-teal: #00838F; + + --bg: #eef1f4; + --panel: #ffffff; + --border: #d8dee5; + --border-soft: #e8ecf0; + --text: #2b3640; + --text-soft: #6b7884; + --text-mute: #9aa6b1; + + --header-h: 50px; + --tabbar-h: 34px; + --sidebar-w: 240px; + --radius: 3px; + --shadow-card: 0 1px 3px rgba(0,0,0,0.08); + --shadow-pop: 0 6px 24px rgba(0,0,0,0.18); + --t: 0.15s ease; } -/* ==================== RESET & BASE ==================== */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} +* { margin: 0; padding: 0; box-sizing: border-box; } html, body { height: 100%; - font-family: 'Roboto', sans-serif; - font-size: 14px; - line-height: 1.5; - color: var(--color-text); - background-color: var(--color-bg); + font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 13px; + color: var(--text); + background: var(--bg); } -/* ==================== LAYOUT ==================== */ -.app-container { +/* ============================================================ + WINDOW TAB BAR (Home | Customers | Invoice ...) — stile desktop + ============================================================ */ +.window-tabs { + height: var(--tabbar-h); + background: var(--mago-blue-dark); display: flex; - height: 100vh; - overflow: hidden; + align-items: flex-end; + padding: 0 6px; + gap: 2px; } - -.sidebar { - width: var(--sidebar-width); - background-color: var(--color-grey-900); - color: white; +.window-tab { display: flex; - flex-direction: column; - box-shadow: 2px 0 8px var(--color-shadow); - overflow-y: auto; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 14px; + background: rgba(255,255,255,0.12); + color: rgba(255,255,255,0.85); + font-size: 12px; + border-radius: 4px 4px 0 0; + text-decoration: none; + transition: background var(--t); + white-space: nowrap; } +.window-tab .material-icons { font-size: 15px; } +.window-tab:hover { background: rgba(255,255,255,0.2); } +.window-tab.active { background: var(--bg); color: var(--mago-blue-dark); font-weight: 600; } -.sidebar-header { - padding: 20px; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); +/* ============================================================ + HEADER + ============================================================ */ +.app-header { + height: var(--header-h); + background: var(--mago-blue); + display: flex; + align-items: center; + padding: 0 14px; + gap: 16px; + color: #fff; + position: relative; + z-index: 30; } +.header-burger { + background: none; border: none; color: #fff; cursor: pointer; + width: 34px; height: 34px; border-radius: 4px; + display: flex; align-items: center; justify-content: center; + transition: background var(--t); +} +.header-burger:hover { background: rgba(255,255,255,0.15); } .logo { - display: flex; - flex-direction: column; - align-items: center; -} - -.logo-text { - font-size: 24px; - font-weight: 700; - color: var(--color-blue); - letter-spacing: 2px; -} - -.logo-version { - font-size: 11px; - color: rgba(255, 255, 255, 0.6); - margin-top: 4px; -} - -.sidebar-nav { - flex: 1; - padding: 12px 0; -} - -.nav-item { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - color: rgba(255, 255, 255, 0.7); + display: flex; align-items: center; gap: 9px; text-decoration: none; - transition: all var(--transition); - border-left: 3px solid transparent; +} +.logo-mark { + width: 34px; height: 34px; border-radius: 6px; + background: #fff; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 1px 2px rgba(0,0,0,0.2); +} +.logo-mark svg { width: 24px; height: 24px; display: block; } +.logo-text { + font-size: 23px; font-weight: 700; letter-spacing: 1px; + color: #fff; line-height: 1; } -.nav-item:hover { - background-color: rgba(255, 255, 255, 0.05); - color: white; -} - -.nav-item.active { - background-color: rgba(25, 118, 210, 0.1); - color: var(--color-blue); - border-left-color: var(--color-blue); -} - -.nav-item i { - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; -} - -.sidebar-footer { - padding: 12px 16px; - border-top: 1px solid rgba(255, 255, 255, 0.1); -} - -.version-info { - display: flex; - flex-direction: column; - font-size: 12px; - color: rgba(255, 255, 255, 0.5); - gap: 4px; -} - -.main-content { +.header-search { flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; + max-width: 560px; + margin: 0 auto; + position: relative; } - -.top-bar { - height: var(--topbar-height); - background-color: white; - border-bottom: 1px solid var(--color-border); - box-shadow: 0 2px 4px var(--color-shadow); - padding: 0 24px; -} - -.top-bar-content { - height: 100%; - display: flex; - align-items: center; - justify-content: space-between; -} - -.page-title { - font-size: 20px; - font-weight: 500; - color: var(--color-text); -} - -.top-bar-actions { - display: flex; - gap: 12px; -} - -.btn-icon { - width: 40px; - height: 40px; - border: none; - background: transparent; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - color: var(--color-grey-700); - border-radius: 50%; - transition: all var(--transition); -} - -.btn-icon:hover { - background-color: var(--color-grey-100); - color: var(--color-blue); -} - -.content-area { - flex: 1; - overflow-y: auto; - padding: 24px; -} - -/* ==================== CONTENT & WIDGETS ==================== */ -.dashboard-container { - display: flex; - flex-direction: column; - gap: 24px; -} - -.widget-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 16px; -} - -.widget-card { - background-color: white; - border-radius: var(--border-radius); - box-shadow: 0 2px 4px var(--color-shadow); - overflow: hidden; - transition: box-shadow var(--transition); - border-left: 4px solid transparent; -} - -.widget-card:hover { - box-shadow: 0 4px 12px var(--color-shadow); -} - -.stat-card { - border-left-color: var(--color-blue); -} - -.widget-header { - padding: 16px; - border-bottom: 1px solid var(--color-border); -} - -.widget-title { - font-size: 16px; - font-weight: 500; - color: var(--color-text); - margin: 0; -} - -.widget-subtitle { - font-size: 12px; - color: var(--color-text-light); - margin: 4px 0 0; -} - -.widget-body { - padding: 16px; -} - -.stat-body { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.stat-icon { - font-size: 32px; - display: flex; - align-items: center; - justify-content: center; - width: 50px; - height: 50px; - border-radius: 50%; - background-color: var(--color-grey-100); -} - -.stat-icon i { - font-size: 32px !important; -} - -.stat-value { - font-size: 24px; - font-weight: 700; - color: var(--color-text); -} - -/* ==================== TABLES ==================== */ -.data-table { +.header-search input { width: 100%; - border-collapse: collapse; - font-size: 13px; -} - -.data-table thead { - background-color: var(--color-grey-100); -} - -.data-table th { - padding: 12px; - text-align: left; - font-weight: 600; - color: var(--color-grey-700); - border-bottom: 2px solid var(--color-border); -} - -.data-table td { - padding: 12px; - border-bottom: 1px solid var(--color-grey-200); -} - -.data-table tbody tr { - transition: background-color var(--transition); -} - -.data-table tbody tr:hover { - background-color: var(--color-grey-50); -} - -.data-table tbody tr.low-stock { - background-color: rgba(211, 47, 47, 0.05); -} - -.data-table-nested { - width: 100%; - border-collapse: collapse; - font-size: 12px; - margin-top: 12px; -} - -.data-table-nested th { - background-color: var(--color-grey-100); - padding: 8px; - text-align: left; - font-weight: 600; - border-bottom: 1px solid var(--color-border); -} - -.data-table-nested td { - padding: 8px; - border-bottom: 1px solid var(--color-grey-200); -} - -.text-right { - text-align: right; -} - -.text-center { - text-align: center; -} - -.text-bold { - font-weight: 600; -} - -.text-danger { - color: var(--color-red); -} - -.text-warning { - color: var(--color-orange); -} - -.text-success { - color: var(--color-green); -} - -.text-orange { - color: var(--color-orange); -} - -/* ==================== BUTTONS ==================== */ -.btn { - padding: 10px 16px; - border: none; - border-radius: var(--border-radius); - font-size: 14px; - font-weight: 500; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 8px; - transition: all var(--transition); -} - -.btn-primary { - background-color: var(--color-blue); - color: white; -} - -.btn-primary:hover { - background-color: #1565C0; - box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3); -} - -.btn-small { - padding: 6px 12px; - font-size: 12px; - border: none; - background: transparent; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 4px; - transition: all var(--transition); -} - -.btn-info { - color: var(--color-blue); -} - -.btn-info:hover { - background-color: rgba(25, 118, 210, 0.1); -} - -.btn-warning { - color: var(--color-orange); -} - -.btn-warning:hover { - background-color: rgba(245, 124, 0, 0.1); -} - -.btn-close { - width: 32px; height: 32px; border: none; - background: transparent; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - color: var(--color-text); - transition: all var(--transition); -} - -.btn-close:hover { - background-color: var(--color-grey-100); -} - -/* ==================== BADGES & STATUS ==================== */ -.badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 12px; border-radius: 16px; - font-size: 12px; - font-weight: 500; + padding: 0 16px 0 38px; + font-size: 13px; + background: #fff; + color: var(--text); + outline: none; +} +.header-search .material-icons { + position: absolute; left: 12px; top: 50%; transform: translateY(-50%); + color: var(--text-mute); font-size: 19px; } -.badge i { - font-size: 14px !important; +.header-right { display: flex; align-items: center; gap: 6px; } +.header-date { + display: flex; flex-direction: column; align-items: flex-end; + font-size: 11px; line-height: 1.25; opacity: 0.95; margin-right: 4px; +} +.header-date .label { opacity: 0.8; } +.header-date .value { font-weight: 600; } +.header-icon-btn { + background: none; border: none; color: #fff; cursor: pointer; + width: 34px; height: 34px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + transition: background var(--t); +} +.header-icon-btn:hover { background: rgba(255,255,255,0.15); } +.header-icon-btn .material-icons { font-size: 21px; } + +/* ============================================================ + LAYOUT BODY + ============================================================ */ +.app-body { display: flex; height: calc(100vh - var(--header-h) - var(--tabbar-h)); } + +/* ---------- Sidebar ---------- */ +.sidebar { + width: var(--sidebar-w); + background: var(--panel); + border-right: 1px solid var(--border); + overflow-y: auto; + flex-shrink: 0; +} +.sidebar-group-title { + display: flex; align-items: center; justify-content: space-between; + padding: 11px 14px; + background: linear-gradient(90deg, var(--mago-blue-light), #f4f9fe); + border-bottom: 1px solid var(--border-soft); + cursor: pointer; +} +.sidebar-group-title .gt-left { display: flex; align-items: center; gap: 8px; } +.sidebar-group-title .gt-mark { + width: 18px; height: 18px; border-radius: 3px; + background: var(--mago-blue); + display: flex; align-items: center; justify-content: center; +} +.sidebar-group-title .gt-mark .material-icons { font-size: 13px; color: #fff; } +.sidebar-group-title span.gt-text { font-weight: 700; color: var(--mago-blue-dark); font-size: 13px; } +.sidebar-group-title .material-icons.chev { font-size: 18px; color: var(--text-soft); } + +.nav-item { + display: flex; align-items: center; gap: 11px; + padding: 9px 14px 9px 18px; + color: var(--text); + text-decoration: none; + border-left: 3px solid transparent; + transition: background var(--t), border-color var(--t); + font-size: 13px; +} +.nav-item:hover { background: #f4f7fa; } +.nav-item.active { + background: var(--mago-blue-light); + border-left-color: var(--mago-blue); + color: var(--mago-blue-dark); + font-weight: 600; +} +.nav-item .material-icons { font-size: 19px; } +.nav-item.disabled { color: var(--text-mute); cursor: default; pointer-events: none; } + +/* icone colorate per modulo */ +.ic-anagrafiche { color: var(--c-blue); } +.ic-amministrazione { color: var(--c-purple); } +.ic-vendite { color: var(--c-green); } +.ic-acquisti { color: var(--c-orange); } +.ic-logistica { color: var(--c-teal); } +.ic-produzione { color: #5d4037; } +.ic-servizi { color: var(--c-red); } +.ic-preferenze { color: var(--text-soft); } + +.sidebar-sub-group { + padding: 9px 14px; font-weight: 700; color: var(--text-soft); + border-top: 1px solid var(--border-soft); font-size: 12px; + display: flex; align-items: center; justify-content: space-between; } -.badge-success { - background-color: rgba(56, 142, 60, 0.1); - color: var(--color-green); +/* ---------- Main ---------- */ +.main { + flex: 1; + overflow-y: auto; + padding: 0; +} +.content-pad { padding: 16px 22px; } + +/* ============================================================ + BREADCRUMB + PAGE HEADER + TABS + ============================================================ */ +.module-bar { + background: var(--panel); + border-bottom: 1px solid var(--border); + padding: 0 22px; +} +.module-title { + font-size: 16px; font-weight: 600; color: var(--text); + padding: 13px 0 4px; +} +.breadcrumb { + font-size: 12px; color: var(--text-soft); padding-bottom: 10px; +} +.breadcrumb a { color: var(--mago-blue); text-decoration: none; } +.breadcrumb a:hover { text-decoration: underline; } +.breadcrumb .sep { margin: 0 5px; color: var(--text-mute); } +.breadcrumb .current { color: var(--text); font-style: italic; } + +.tab-strip { + display: flex; gap: 2px; + border-bottom: 1px solid var(--border); + background: var(--panel); + padding: 0 22px; + overflow-x: auto; +} +.tab-strip a { + padding: 9px 16px; + font-size: 13px; + color: var(--text-soft); + text-decoration: none; + border-bottom: 2px solid transparent; + white-space: nowrap; + transition: color var(--t), border-color var(--t); +} +.tab-strip a:hover { color: var(--mago-blue); } +.tab-strip a.active { + color: var(--mago-blue-dark); + border-bottom-color: var(--mago-teal); + font-weight: 600; } -.badge-warning { - background-color: rgba(251, 192, 45, 0.1); - color: #F57F17; +/* ============================================================ + MENU CARDS (landing moduli) + ============================================================ */ +.cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; + padding: 18px 22px; + align-items: start; } - -.badge-danger { - background-color: rgba(211, 47, 47, 0.1); - color: var(--color-red); +.menu-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + overflow: hidden; } - -.status-draft { - background-color: rgba(158, 158, 158, 0.1); - color: var(--color-grey-700); +.menu-card-header { + display: flex; align-items: center; justify-content: space-between; + padding: 11px 14px; + border-bottom: 2px solid var(--mago-blue); } - -.status-confirmed { - background-color: rgba(25, 118, 210, 0.1); - color: var(--color-blue); +.menu-card-header h3 { font-size: 14px; font-weight: 600; color: var(--mago-blue-dark); } +.menu-card-header .collapse-dot { + color: var(--text-mute); font-size: 16px; letter-spacing: -1px; } - -.status-partially_received { - background-color: rgba(245, 124, 0, 0.1); - color: var(--color-orange); +.menu-card-body { padding: 6px 0; } +.menu-link { + display: flex; align-items: center; gap: 10px; + padding: 8px 14px; + color: var(--text); + text-decoration: none; + transition: background var(--t); } - -.status-received { - background-color: rgba(56, 142, 60, 0.1); - color: var(--color-green); +.menu-link:hover { background: var(--mago-blue-light); } +.menu-link .ml-icon { font-size: 18px; width: 20px; text-align: center; } +.menu-link .ml-text { flex: 1; font-size: 13px; } +.menu-link .ml-star { + color: var(--text-mute); font-size: 17px; + opacity: 0; transition: opacity var(--t), color var(--t); } +.menu-link:hover .ml-star { opacity: 1; } +.menu-link .ml-star:hover { color: var(--c-orange); } +.mtype-list { color: var(--c-blue); } +.mtype-form { color: var(--c-orange); } +.mtype-print { color: var(--c-green); } +.mtype-proc { color: var(--c-purple); } -.status-cancelled { - background-color: rgba(211, 47, 47, 0.1); - color: var(--color-red); +/* ============================================================ + PANELS / TABELLE DATI + ============================================================ */ +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + margin-bottom: 16px; + overflow: hidden; } +.panel-header { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + background: #fafbfc; +} +.panel-header h3 { + font-size: 13px; font-weight: 600; color: var(--mago-blue-dark); + display: flex; align-items: center; gap: 7px; +} +.panel-header h3 .material-icons { font-size: 17px; } +.panel-body { padding: 0; } +.panel-body.padded { padding: 14px; } -/* ==================== ALERTS ==================== */ -.alert { - padding: 12px 16px; - border-radius: var(--border-radius); - display: flex; - align-items: center; - gap: 12px; - font-size: 14px; +.data-table { width: 100%; border-collapse: collapse; font-size: 12.5px; } +.data-table thead th { + background: #f3f6f9; + padding: 9px 12px; + text-align: left; + font-weight: 600; + color: var(--text-soft); + border-bottom: 1px solid var(--border); + white-space: nowrap; + position: sticky; top: 0; +} +.data-table tbody td { + padding: 8px 12px; + border-bottom: 1px solid var(--border-soft); +} +.data-table tbody tr { transition: background var(--t); cursor: default; } +.data-table tbody tr:hover { background: #f6f9fc; } +.data-table tbody tr.low-stock { background: rgba(211,47,47,0.045); } +.data-table tbody tr.row-link { cursor: pointer; } + +.data-table-nested { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; } +.data-table-nested th { + background: #f3f6f9; padding: 7px 10px; text-align: left; + font-weight: 600; color: var(--text-soft); border-bottom: 1px solid var(--border); +} +.data-table-nested td { padding: 7px 10px; border-bottom: 1px solid var(--border-soft); } + +.text-right { text-align: right; } +.text-center { text-align: center; } +.text-bold { font-weight: 600; } +.mono { font-family: "Consolas", "SF Mono", monospace; } +.text-danger { color: var(--c-red); } +.text-warning { color: var(--c-orange); } +.text-success { color: var(--c-green); } +.text-blue { color: var(--mago-blue); } +.text-mute { color: var(--text-mute); } + +/* ============================================================ + KPI / STAT CARDS (dashboard) + ============================================================ */ +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; margin-bottom: 16px; } +.kpi-card { + background: var(--panel); + border: 1px solid var(--border); + border-left: 4px solid var(--c-blue); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + padding: 14px 16px; +} +.kpi-card .kpi-title { font-size: 12px; color: var(--text-soft); margin-bottom: 10px; } +.kpi-card .kpi-row { display: flex; align-items: center; justify-content: space-between; } +.kpi-card .kpi-icon { + width: 42px; height: 42px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + background: #f2f5f8; +} +.kpi-card .kpi-icon .material-icons { font-size: 24px; } +.kpi-card .kpi-value { font-size: 23px; font-weight: 700; } -.alert i { - font-size: 20px !important; +.dash-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +@media (max-width: 1100px) { .dash-grid-2 { grid-template-columns: 1fr; } } + +/* ============================================================ + TODO LIST / COSE DA FARE + ============================================================ */ +.todo-list { display: flex; flex-direction: column; } +.todo-item { + display: flex; align-items: center; gap: 12px; + padding: 11px 14px; + border-bottom: 1px solid var(--border-soft); + transition: background var(--t); +} +.todo-item:last-child { border-bottom: none; } +.todo-item:hover { background: #f6f9fc; } +.todo-icon { + width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; +} +.todo-icon .material-icons { font-size: 20px; } +.todo-icon.c-red { background: rgba(211,47,47,0.12); color: var(--c-red); } +.todo-icon.c-orange { background: rgba(245,124,0,0.14); color: var(--c-orange); } +.todo-icon.c-blue { background: rgba(21,119,190,0.12); color: var(--mago-blue); } +.todo-icon.c-teal { background: rgba(0,131,143,0.12); color: var(--c-teal); } +.todo-icon.c-green { background: rgba(56,142,60,0.12); color: var(--c-green); } +.todo-body { flex: 1; } +.todo-text { font-size: 13px; color: var(--text); } +.todo-amount { font-size: 12px; color: var(--text-soft); margin-top: 2px; } +.todo-action { + font-size: 12px; font-weight: 600; color: var(--mago-blue); + text-decoration: none; padding: 5px 12px; border-radius: 14px; + background: var(--mago-blue-light); white-space: nowrap; + transition: background var(--t); +} +.todo-action:hover { background: #d6e9f9; } +.todo-empty { padding: 30px; text-align: center; color: var(--text-mute); } +.todo-empty .material-icons { font-size: 42px; color: var(--c-green); display: block; margin-bottom: 8px; } + +/* ---------- Cashflow ---------- */ +.cashflow-box { padding: 16px; } +.cashflow-row { + display: flex; align-items: center; justify-content: space-between; + padding: 9px 0; border-bottom: 1px solid var(--border-soft); +} +.cashflow-row:last-child { border-bottom: none; } +.cashflow-row .cf-label { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-soft); } +.cashflow-row .cf-label .material-icons { font-size: 18px; } +.cashflow-row .cf-value { font-size: 15px; font-weight: 700; } +.cashflow-row.cf-net { border-top: 2px solid var(--border); margin-top: 4px; padding-top: 12px; } +.cashflow-row.cf-net .cf-label { font-weight: 700; color: var(--text); } +.cashflow-row.cf-net .cf-value { font-size: 18px; } + +/* ============================================================ + BADGES / STATUS + ============================================================ */ +.badge { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 9px; border-radius: 11px; + font-size: 11px; font-weight: 600; white-space: nowrap; +} +.badge .material-icons { font-size: 13px; } +.badge-success { background: rgba(56,142,60,0.12); color: var(--c-green); } +.badge-warning { background: rgba(245,124,0,0.14); color: #e06f00; } +.badge-danger { background: rgba(211,47,47,0.12); color: var(--c-red); } +.badge-info { background: rgba(21,119,190,0.12); color: var(--mago-blue); } +.badge-grey { background: rgba(120,130,140,0.14); color: var(--text-soft); } +.badge-load { background: rgba(56,142,60,0.12); color: var(--c-green); } +.badge-unload { background: rgba(211,47,47,0.12); color: var(--c-red); } + +.status-draft { background: rgba(120,130,140,0.14); color: var(--text-soft); } +.status-confirmed { background: rgba(21,119,190,0.12); color: var(--mago-blue); } +.status-printed { background: rgba(0,131,143,0.12); color: var(--c-teal); } +.status-issued { background: rgba(56,142,60,0.12); color: var(--c-green); } +.status-posted { background: rgba(123,31,162,0.12); color: var(--c-purple); } +.status-partially_received { background: rgba(245,124,0,0.14); color: #e06f00; } +.status-received { background: rgba(56,142,60,0.12); color: var(--c-green); } +.status-cancelled { background: rgba(211,47,47,0.12); color: var(--c-red); } + +/* ============================================================ + BUTTONS + ============================================================ */ +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 14px; border: none; border-radius: var(--radius); + font-size: 13px; font-weight: 500; cursor: pointer; + transition: background var(--t), box-shadow var(--t); +} +.btn .material-icons { font-size: 17px; } +.btn-primary { background: var(--mago-blue); color: #fff; } +.btn-primary:hover { background: var(--mago-blue-dark); } +.btn-ghost { background: #fff; color: var(--mago-blue); border: 1px solid var(--border); } +.btn-ghost:hover { background: var(--mago-blue-light); } + +.btn-icon-sm { + background: none; border: none; cursor: pointer; + width: 28px; height: 28px; border-radius: 4px; + display: inline-flex; align-items: center; justify-content: center; + transition: background var(--t); +} +.btn-icon-sm .material-icons { font-size: 17px; } +.btn-icon-sm.act-view { color: var(--mago-blue); } +.btn-icon-sm.act-edit { color: var(--c-orange); } +.btn-icon-sm.act-cart { color: var(--c-green); } +.btn-icon-sm:hover { background: #eef2f6; } + +.page-toolbar { + display: flex; align-items: center; gap: 10px; + padding: 12px 22px; + flex-wrap: wrap; +} +.page-toolbar .spacer { flex: 1; } + +/* ---------- Form toolbar (stile desktop document) ---------- */ +.doc-toolbar { + display: flex; align-items: center; gap: 2px; + background: #f3f6f9; + border-bottom: 1px solid var(--border); + padding: 5px 10px; +} +.doc-toolbar .tb-btn { + background: none; border: none; cursor: pointer; + width: 30px; height: 30px; border-radius: 4px; + display: flex; align-items: center; justify-content: center; + color: var(--mago-blue-dark); transition: background var(--t); +} +.doc-toolbar .tb-btn:hover { background: #e2eaf1; } +.doc-toolbar .tb-btn .material-icons { font-size: 18px; } +.doc-toolbar .tb-sep { width: 1px; height: 20px; background: var(--border); margin: 0 5px; } + +/* ============================================================ + ALERTS + ============================================================ */ +.alert { + display: flex; align-items: center; gap: 10px; + padding: 10px 14px; border-radius: var(--radius); + font-size: 13px; margin-bottom: 14px; +} +.alert .material-icons { font-size: 19px; } +.alert-info { background: rgba(21,119,190,0.1); color: var(--mago-blue-dark); } +.alert-warning { background: rgba(245,124,0,0.12); color: #b85c00; } +.alert-danger { background: rgba(211,47,47,0.1); color: var(--c-red); } + +/* ============================================================ + FORM (document detail, stile Mago4) + ============================================================ */ +.doc-form { display: flex; } +.doc-form-tabs { + width: 190px; flex-shrink: 0; + border-right: 1px solid var(--border); + background: #fafbfc; +} +.doc-form-tab { + display: flex; align-items: center; gap: 9px; + padding: 10px 14px; + font-size: 13px; color: var(--text); + border-left: 3px solid transparent; cursor: pointer; + transition: background var(--t); +} +.doc-form-tab .material-icons { font-size: 18px; color: var(--mago-blue); } +.doc-form-tab:hover { background: #f0f4f8; } +.doc-form-tab.active { + background: var(--mago-blue-light); border-left-color: var(--mago-blue); + font-weight: 600; color: var(--mago-blue-dark); +} +.doc-form-content { flex: 1; padding: 16px 20px; } + +.form-section-title { + font-size: 13px; font-weight: 700; color: var(--mago-blue); + border-bottom: 1px solid var(--border-soft); + padding-bottom: 6px; margin: 4px 0 14px; +} +.form-grid-2 { + display: grid; grid-template-columns: 1fr 1fr; gap: 8px 40px; + margin-bottom: 18px; +} +@media (max-width: 900px) { .form-grid-2 { grid-template-columns: 1fr; } } +.field-row { + display: grid; grid-template-columns: 130px 1fr; align-items: center; + gap: 10px; padding: 4px 0; +} +.field-row label { font-size: 12.5px; color: var(--text-soft); } +.field-row .field-value { + font-size: 13px; color: var(--text); + padding: 5px 8px; background: #f7f9fb; + border: 1px solid var(--border-soft); border-radius: 3px; + min-height: 28px; display: flex; align-items: center; } -.alert-info { - background-color: rgba(25, 118, 210, 0.1); - color: var(--color-blue); +.totals-box { + display: flex; justify-content: flex-end; gap: 30px; + padding: 14px; background: #fafbfc; + border-top: 1px solid var(--border); } +.totals-box .t-item { text-align: right; } +.totals-box .t-item .t-label { font-size: 11px; color: var(--text-soft); } +.totals-box .t-item .t-value { font-size: 16px; font-weight: 700; } +.totals-box .t-item.grand .t-value { color: var(--mago-blue-dark); font-size: 20px; } -.alert-warning { - background-color: rgba(245, 124, 0, 0.1); - color: var(--color-orange); -} - -.alert-danger { - background-color: rgba(211, 47, 47, 0.1); - color: var(--color-red); -} - -/* ==================== PAGE ELEMENTS ==================== */ -.page-header { - display: flex; - gap: 12px; - margin-bottom: 20px; - align-items: center; -} - -.summary-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 16px; - margin-top: 24px; -} - -.stat-summary { - background-color: white; - padding: 16px; - border-radius: var(--border-radius); - border-left: 4px solid var(--color-blue); - box-shadow: 0 2px 4px var(--color-shadow); -} - -.stat-label { - font-size: 12px; - color: var(--color-text-light); - margin-bottom: 8px; -} - -.stat-number { - font-size: 28px; - font-weight: 700; - color: var(--color-text); -} - -/* ==================== MODAL ==================== */ +/* ============================================================ + MODAL + ============================================================ */ .modal { - display: none; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - z-index: 1000; - align-items: center; - justify-content: center; + display: none; position: fixed; inset: 0; + background: rgba(20,30,40,0.5); z-index: 100; + align-items: flex-start; justify-content: center; + padding: 40px 20px; overflow-y: auto; } - -.modal.show { - display: flex; -} - +.modal.show { display: flex; } .modal-content { - background-color: white; - border-radius: var(--border-radius); - max-width: 600px; - width: 90%; - max-height: 90vh; - overflow-y: auto; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + background: #fff; border-radius: var(--radius); + width: 100%; max-width: 760px; + box-shadow: var(--shadow-pop); + animation: modalIn 0.18s ease; } - +@keyframes modalIn { from { transform: translateY(-12px); opacity: 0; } to { transform: none; opacity: 1; } } .modal-header { - padding: 20px; - border-bottom: 1px solid var(--color-border); - display: flex; - align-items: center; - justify-content: space-between; + display: flex; align-items: center; justify-content: space-between; + padding: 0; border-bottom: 1px solid var(--border); + background: var(--mago-blue); color: #fff; + border-radius: var(--radius) var(--radius) 0 0; } - -.modal-header h2 { - font-size: 18px; - margin: 0; - color: var(--color-text); +.modal-header h2 { font-size: 14px; font-weight: 600; padding: 12px 16px; } +.modal-close { + background: none; border: none; color: #fff; cursor: pointer; + width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; + transition: background var(--t); } +.modal-close:hover { background: rgba(255,255,255,0.18); } +.modal-body { padding: 0; } -.modal-body { - padding: 20px; -} - -.detail-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 20px; -} - -.detail-item { - display: flex; - flex-direction: column; - gap: 4px; -} - -.detail-item label { - font-size: 12px; - color: var(--color-text-light); - font-weight: 600; - text-transform: uppercase; -} - -.detail-item span { - font-size: 14px; - color: var(--color-text); -} - -/* ==================== HIGHLIGHTS ==================== */ -.highlight-danger { - background-color: rgba(211, 47, 47, 0.05) !important; -} - -.small-icon { - font-size: 16px !important; - vertical-align: middle; - margin-left: 4px; -} - -/* ==================== SCROLLBAR ==================== */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: var(--color-bg); -} - -::-webkit-scrollbar-thumb { - background: var(--color-grey-300); - border-radius: 4px; -} - -::-webkit-scrollbar-thumb:hover { - background: var(--color-grey-500); -} - -/* ==================== RESPONSIVE ==================== */ -@media (max-width: 768px) { - .sidebar { - width: 60px; - } - - .nav-item span { - display: none; - } - - .logo-version { - display: none; - } - - .widget-row { - grid-template-columns: 1fr; - } - - .detail-grid { - grid-template-columns: 1fr; - } - - .stat-body { - flex-direction: column; - } +/* ============================================================ + SCROLLBAR + ============================================================ */ +::-webkit-scrollbar { width: 9px; height: 9px; } +::-webkit-scrollbar-track { background: #eef1f4; } +::-webkit-scrollbar-thumb { background: #c4ccd4; border-radius: 5px; } +::-webkit-scrollbar-thumb:hover { background: #a8b2bc; } + +.material-icons { + font-family: 'Material Icons'; + font-weight: normal; font-style: normal; + display: inline-block; line-height: 1; + text-transform: none; letter-spacing: normal; + white-space: nowrap; direction: ltr; + -webkit-font-smoothing: antialiased; } diff --git a/static/js/app.js b/static/js/app.js index bb17323..6af34ea 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,120 +1,56 @@ -// Utility functions for API calls -async function apiCall(endpoint, method = 'GET', data = null) { - const options = { - method: method, - headers: { - 'Content-Type': 'application/json', - } - }; +// ============================================================ +// Mago4 Demo — helper frontend +// ============================================================ - if (data) { - options.body = JSON.stringify(data); - } - - try { - const response = await fetch(endpoint, options); - if (!response.ok) { - throw new Error(`API Error: ${response.statusText}`); - } - return await response.json(); - } catch (error) { - console.error('API call failed:', error); - showNotification('Errore nella richiesta', 'error'); - throw error; - } +// Genera una field-row in stile form Mago4 +function field(label, value) { + return `