Add Mago4 demo ERP with Python FastAPI
Implements a working in-memory ERP demo inspired by Mago4, covering the core modules: dashboard with KPI widgets, customers, suppliers, products, sales orders, purchase orders, and warehouse inventory. - FastAPI backend with Pydantic models and REST API (auto-docs at /docs) - Jinja2 HTML templates with Mago4-style UI (dark sidebar, blue/orange/ green/red palette, Material Design icons) - In-memory fake database seeded with sample customers, suppliers, products, and orders — ready to swap for SQLAlchemy + SQL Server - Workarounds for Python 3.14 + Starlette 1.3 / Jinja2 compatibility (cache_size=0, new TemplateResponse(request, name, ctx) signature) - launch.json for one-click preview; run.bat for manual startup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "mago4-demo",
|
||||
"runtimeExecutable": "python",
|
||||
"runtimeArgs": ["main.py"],
|
||||
"port": 8000,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
-410
@@ -1,416 +1,34 @@
|
||||
# ---> VisualStudio
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||
!Directory.Build.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.tlog
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||
*.vbp
|
||||
|
||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||
*.dsw
|
||||
*.dsp
|
||||
|
||||
# Visual Studio 6 technical files
|
||||
*.ncb
|
||||
*.aps
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# Visual Studio History (VSHistory) files
|
||||
.vshistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# VS Code files for those working on multiple tools
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Windows Installer files from build outputs
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
|
||||
# ---> VisualStudioCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
# FastAPI
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -1,3 +1,118 @@
|
||||
# MagoRevisited
|
||||
# Mago4 Demo - ERP Gestionale
|
||||
|
||||
Nuovo gestionale ERP
|
||||
Demo funzionante di un gestionale ERP ispirato a **Mago4**, realizzato con **Python FastAPI**.
|
||||
|
||||
## 🎯 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
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Installare dipendenze
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Avviare l'applicazione
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
L'app sarà disponibile su: **http://127.0.0.1:8000**
|
||||
|
||||
### 3. Accedere alle sezioni
|
||||
|
||||
- **Dashboard**: http://127.0.0.1:8000/
|
||||
- **API Docs**: http://127.0.0.1:8000/docs
|
||||
- **Clienti**: http://127.0.0.1:8000/customers
|
||||
- **Fornitori**: http://127.0.0.1:8000/suppliers
|
||||
- **Articoli**: http://127.0.0.1:8000/products
|
||||
- **Ordini Vendita**: http://127.0.0.1:8000/sales-orders
|
||||
- **Ordini Acquisto**: http://127.0.0.1:8000/purchase-orders
|
||||
- **Magazzino**: http://127.0.0.1:8000/warehouse
|
||||
|
||||
## 📁 Struttura del Progetto
|
||||
|
||||
```
|
||||
mago4-demo/
|
||||
├── main.py # FastAPI app principale
|
||||
├── models.py # Modelli dati (Pydantic)
|
||||
├── database.py # Database in-memoria
|
||||
├── requirements.txt # Dipendenze Python
|
||||
├── templates/ # Template HTML (Jinja2)
|
||||
│ ├── base.html # Layout con sidebar
|
||||
│ ├── dashboard.html # Dashboard KPI
|
||||
│ ├── customers.html # Gestione clienti
|
||||
│ ├── suppliers.html # Gestione fornitori
|
||||
│ ├── products.html # Catalogo articoli
|
||||
│ ├── sales_orders.html # Ordini vendita
|
||||
│ ├── purchase_orders.html # Ordini acquisto
|
||||
│ └── warehouse.html # Magazzino
|
||||
└── static/ # Assets frontend
|
||||
├── css/style.css # Stili (colori Mago4)
|
||||
└── js/app.js # Funzionalità JS
|
||||
```
|
||||
|
||||
## 🎨 Design & Colori (ispirato a Mago4)
|
||||
|
||||
- **Blu**: #1976D2 (Azioni, statistiche)
|
||||
- **Arancione**: #F57C00 (Avvisi, acquisti)
|
||||
- **Verde**: #388E3C (Conferme, OK)
|
||||
- **Rosso**: #D32F2F (Errori, sottosorta)
|
||||
|
||||
## 📊 Dati Inclusi
|
||||
|
||||
- **4 Clienti**: Acme, TechFlow, European Logistics, Alpine Manufacturing
|
||||
- **3 Fornitori**: Global Components, Asia Electronics, Nordic Materials
|
||||
- **5 Articoli**: Widget, Connectors, Brackets, Bearings, Control Module
|
||||
- **3 Ordini Vendita**: SO-2024-0001/0002/0003
|
||||
- **3 Ordini Acquisto**: PO-2024-0001/0002/0003
|
||||
|
||||
## 🔌 API REST
|
||||
|
||||
Tutti gli endpoint sono auto-documentati su `/docs` (Swagger UI)
|
||||
|
||||
### Principali endpoint:
|
||||
- `GET/POST /api/customers`
|
||||
- `GET/POST /api/suppliers`
|
||||
- `GET/POST /api/products`
|
||||
- `GET/POST /api/sales-orders`
|
||||
- `GET/POST /api/purchase-orders`
|
||||
- `GET /api/warehouse/stats`
|
||||
- `GET /api/info`
|
||||
|
||||
## 🔄 Migrazione a SQL
|
||||
|
||||
Pronto per SQLAlchemy + SQL Server/PostgreSQL:
|
||||
```bash
|
||||
pip install sqlalchemy sqlmodel
|
||||
```
|
||||
|
||||
Sostituire `database.py` con modelli SQLModel e aggiungere connection string.
|
||||
|
||||
## ⚙️ Tech Stack
|
||||
|
||||
- **Backend**: FastAPI (async Python)
|
||||
- **Template**: Jinja2
|
||||
- **Validazione**: Pydantic
|
||||
- **DB Demo**: In-memoria (list)
|
||||
- **Frontend**: HTML5 + CSS3 + JavaScript vanilla
|
||||
- **Icons**: Material Design Icons
|
||||
- **Font**: Roboto (Google Fonts)
|
||||
|
||||
## ⚠️ Note
|
||||
|
||||
- Demo con dati fittizi in-memoria
|
||||
- Per produzione: aggiungere auth, DB reale, validazione completa, HTTPS
|
||||
- Facilmente estendibile con nuovi moduli
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Mago4 Demo Configuration
|
||||
# Questo file può essere usato per estendere la configurazione
|
||||
|
||||
[Database]
|
||||
TYPE=in-memory
|
||||
# Per SQL Server: mssql+pyodbc://user:password@server/dbname?driver=ODBC+Driver+17+for+SQL+Server
|
||||
# Per PostgreSQL: postgresql://user:password@localhost/dbname
|
||||
# Per SQLite: sqlite:///./mago4.db
|
||||
|
||||
[Server]
|
||||
HOST=127.0.0.1
|
||||
PORT=8000
|
||||
DEBUG=True
|
||||
RELOAD=True
|
||||
|
||||
[Features]
|
||||
ENABLE_AUTH=False
|
||||
ENABLE_PDF_EXPORT=False
|
||||
ENABLE_EMAIL_NOTIFICATIONS=False
|
||||
ENABLE_MULTICOMPANY=False
|
||||
|
||||
[Pagination]
|
||||
DEFAULT_PAGE_SIZE=20
|
||||
MAX_PAGE_SIZE=100
|
||||
|
||||
[Validation]
|
||||
MIN_CREDIT_LIMIT=1000
|
||||
MIN_PAYMENT_TERMS=1
|
||||
MAX_REORDER_QUANTITY=10000
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
from datetime import datetime, timedelta
|
||||
from models import (
|
||||
Customer, Supplier, Product, OrderLine, SalesOrder, PurchaseOrder, OrderStatus
|
||||
)
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self):
|
||||
self.customers = self._init_customers()
|
||||
self.suppliers = self._init_suppliers()
|
||||
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
|
||||
|
||||
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
|
||||
),
|
||||
]
|
||||
|
||||
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
|
||||
),
|
||||
]
|
||||
|
||||
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
|
||||
),
|
||||
]
|
||||
|
||||
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)
|
||||
),
|
||||
]
|
||||
|
||||
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)
|
||||
),
|
||||
]
|
||||
|
||||
# CRUD Operations
|
||||
def get_customers(self) -> List[Customer]:
|
||||
return self.customers
|
||||
|
||||
def get_customer(self, customer_id: int) -> Optional[Customer]:
|
||||
return next((c for c in self.customers if c.id == customer_id), None)
|
||||
|
||||
def create_customer(self, customer: Customer) -> Customer:
|
||||
customer.id = self.next_customer_id
|
||||
self.next_customer_id += 1
|
||||
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 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 create_supplier(self, supplier: Supplier) -> Supplier:
|
||||
supplier.id = self.next_supplier_id
|
||||
self.next_supplier_id += 1
|
||||
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 create_product(self, product: Product) -> Product:
|
||||
product.id = self.next_product_id
|
||||
self.next_product_id += 1
|
||||
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)
|
||||
|
||||
def create_sales_order(self, order: SalesOrder) -> SalesOrder:
|
||||
order.id = self.next_so_id
|
||||
self.next_so_id += 1
|
||||
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 create_purchase_order(self, order: PurchaseOrder) -> PurchaseOrder:
|
||||
order.id = self.next_po_id
|
||||
self.next_po_id += 1
|
||||
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])
|
||||
|
||||
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]
|
||||
|
||||
return {
|
||||
"sales_to_fulfill": sales_to_fulfill,
|
||||
"purchase_to_receive": purchase_to_receive,
|
||||
"total_receivable": total_receivable,
|
||||
"total_payable": total_payable,
|
||||
"low_stock_count": len(low_stock_items),
|
||||
"low_stock_items": low_stock_items,
|
||||
"total_customers": len(self.customers),
|
||||
"total_suppliers": len(self.suppliers),
|
||||
"total_products": len(self.products),
|
||||
}
|
||||
|
||||
|
||||
# Global database instance
|
||||
db = FakeDatabase()
|
||||
@@ -0,0 +1,304 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from starlette.requests import Request
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
from models import (
|
||||
Customer, Supplier, Product, SalesOrder, PurchaseOrder,
|
||||
OrderLine, OrderStatus, Dashboard, DashboardWidget
|
||||
)
|
||||
from database import db
|
||||
|
||||
app = FastAPI(
|
||||
title="Mago4 Demo",
|
||||
description="Demo gestionale ERP con Python FastAPI",
|
||||
version="1.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)
|
||||
_jinja_env = Environment(
|
||||
loader=FileSystemLoader(os.path.join(BASE_DIR, "templates")),
|
||||
autoescape=True,
|
||||
cache_size=0,
|
||||
)
|
||||
templates = Jinja2Templates(env=_jinja_env)
|
||||
|
||||
|
||||
# ==================== 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
|
||||
})
|
||||
|
||||
|
||||
# ==================== 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
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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]
|
||||
})
|
||||
|
||||
|
||||
# ==================== 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)
|
||||
|
||||
|
||||
@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]
|
||||
})
|
||||
|
||||
|
||||
# ==================== PURCHASE ORDERS ====================
|
||||
@app.get("/api/purchase-orders", tags=["Purchase Orders"])
|
||||
async def list_purchase_orders():
|
||||
return db.get_purchase_orders()
|
||||
|
||||
|
||||
@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.post("/api/purchase-orders", tags=["Purchase Orders"])
|
||||
async def create_purchase_order(order: PurchaseOrder):
|
||||
return db.create_purchase_order(order)
|
||||
|
||||
|
||||
@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]
|
||||
})
|
||||
|
||||
|
||||
# ==================== 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()
|
||||
}
|
||||
|
||||
|
||||
@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"]
|
||||
})
|
||||
|
||||
|
||||
# ==================== INFO ====================
|
||||
@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
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
port = int(os.environ.get("PORT", 8000))
|
||||
uvicorn.run(app, host="127.0.0.1", port=port)
|
||||
@@ -0,0 +1,193 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class OrderStatus(str, Enum):
|
||||
DRAFT = "draft"
|
||||
CONFIRMED = "confirmed"
|
||||
PARTIALLY_RECEIVED = "partially_received"
|
||||
RECEIVED = "received"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class OrderType(str, Enum):
|
||||
SALES = "sales"
|
||||
PURCHASE = "purchase"
|
||||
|
||||
|
||||
class Customer(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
email: str
|
||||
phone: str
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Supplier(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
email: str
|
||||
phone: str
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Product(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
description: str
|
||||
category: str
|
||||
unit_price: float
|
||||
quantity_in_stock: int
|
||||
reorder_level: int
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class OrderLine(BaseModel):
|
||||
id: int
|
||||
product_id: int
|
||||
product_code: str
|
||||
product_description: str
|
||||
quantity: float
|
||||
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
|
||||
order_number: str
|
||||
order_date: datetime
|
||||
customer_id: int
|
||||
customer_name: str
|
||||
status: OrderStatus
|
||||
lines: List[OrderLine]
|
||||
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
|
||||
order_number: str
|
||||
order_date: datetime
|
||||
supplier_id: int
|
||||
supplier_name: str
|
||||
status: OrderStatus
|
||||
lines: List[OrderLine]
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class DashboardWidget(BaseModel):
|
||||
title: str
|
||||
subtitle: Optional[str] = None
|
||||
style: str # "stats", "chart", "grid"
|
||||
size: str = "small" # "small", "medium", "large"
|
||||
icon: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
value: Optional[str] = None
|
||||
format: Optional[str] = None # "money", "number", "date"
|
||||
data: Optional[dict] = None
|
||||
|
||||
|
||||
class Dashboard(BaseModel):
|
||||
widgets: List[List[DashboardWidget]]
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn==0.24.0
|
||||
pydantic==2.5.0
|
||||
python-dateutil==2.8.2
|
||||
jinja2==3.1.2
|
||||
@@ -0,0 +1,29 @@
|
||||
@echo off
|
||||
REM Mago4 Demo - Script di avvio
|
||||
|
||||
echo.
|
||||
echo ╔════════════════════════════════════════════════════════════╗
|
||||
echo ║ Mago4 Demo - ERP Gestionale (FastAPI) ║
|
||||
echo ╚════════════════════════════════════════════════════════════╝
|
||||
echo.
|
||||
|
||||
REM Verificare se Python è installato
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERRORE] Python non trovato. Installare Python 3.8+
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [1] Controllo dipendenze...
|
||||
pip install -r requirements.txt
|
||||
|
||||
echo.
|
||||
echo [2] Avvio applicazione...
|
||||
echo.
|
||||
echo Dashboard disponibile su: http://127.0.0.1:8000
|
||||
echo API Documentation su: http://127.0.0.1:8000/docs
|
||||
echo.
|
||||
echo Premi CTRL+C per fermere il server
|
||||
echo.
|
||||
|
||||
python main.py
|
||||
@@ -0,0 +1,671 @@
|
||||
/* ==================== CSS VARIABLES & COLORS ==================== */
|
||||
: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);
|
||||
|
||||
--sidebar-width: 250px;
|
||||
--topbar-height: 60px;
|
||||
--spacing-unit: 8px;
|
||||
--border-radius: 4px;
|
||||
--transition: 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* ==================== RESET & BASE ==================== */
|
||||
* {
|
||||
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);
|
||||
}
|
||||
|
||||
/* ==================== LAYOUT ==================== */
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background-color: var(--color-grey-900);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 2px 0 8px var(--color-shadow);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.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);
|
||||
text-decoration: none;
|
||||
transition: all var(--transition);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.badge i {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: rgba(56, 142, 60, 0.1);
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: rgba(251, 192, 45, 0.1);
|
||||
color: #F57F17;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background-color: rgba(211, 47, 47, 0.1);
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
background-color: rgba(158, 158, 158, 0.1);
|
||||
color: var(--color-grey-700);
|
||||
}
|
||||
|
||||
.status-confirmed {
|
||||
background-color: rgba(25, 118, 210, 0.1);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.status-partially_received {
|
||||
background-color: rgba(245, 124, 0, 0.1);
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
.status-received {
|
||||
background-color: rgba(56, 142, 60, 0.1);
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
background-color: rgba(211, 47, 47, 0.1);
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
/* ==================== ALERTS ==================== */
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert i {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: rgba(25, 118, 210, 0.1);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Utility functions for API calls
|
||||
async function apiCall(endpoint, method = 'GET', data = null) {
|
||||
const options = {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Notification system
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.textContent = message;
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
background-color: ${type === 'error' ? '#D32F2F' : '#1976D2'};
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
z-index: 2000;
|
||||
animation: slideIn 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
setTimeout(() => notification.remove(), 3000);
|
||||
}
|
||||
|
||||
// Format currency
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('it-IT', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('it-IT');
|
||||
}
|
||||
|
||||
// Initialize page animations
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add smooth scrolling
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
// Close modals with Escape
|
||||
if (e.key === 'Escape') {
|
||||
document.querySelectorAll('.modal.show').forEach(modal => {
|
||||
modal.classList.remove('show');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize tooltips
|
||||
document.querySelectorAll('[title]').forEach(element => {
|
||||
element.style.cursor = 'help';
|
||||
});
|
||||
});
|
||||
|
||||
// Add CSS animation for notifications
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Mago4 Demo - ERP{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<span class="logo-text">Mago4</span>
|
||||
<span class="logo-version">Demo</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/" class="nav-item {% if request.url.path == '/' %}active{% endif %}">
|
||||
<i class="material-icons">dashboard</i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a href="/customers" class="nav-item {% if 'customers' in request.url.path %}active{% endif %}">
|
||||
<i class="material-icons">people</i>
|
||||
<span>Clienti</span>
|
||||
</a>
|
||||
<a href="/suppliers" class="nav-item {% if 'suppliers' in request.url.path %}active{% endif %}">
|
||||
<i class="material-icons">local_shipping</i>
|
||||
<span>Fornitori</span>
|
||||
</a>
|
||||
<a href="/products" class="nav-item {% if 'products' in request.url.path %}active{% endif %}">
|
||||
<i class="material-icons">inventory_2</i>
|
||||
<span>Articoli</span>
|
||||
</a>
|
||||
<a href="/sales-orders" class="nav-item {% if 'sales' in request.url.path %}active{% endif %}">
|
||||
<i class="material-icons">receipt</i>
|
||||
<span>Ordini Vendita</span>
|
||||
</a>
|
||||
<a href="/purchase-orders" class="nav-item {% if 'purchase' in request.url.path %}active{% endif %}">
|
||||
<i class="material-icons">shopping_cart</i>
|
||||
<span>Ordini Acquisto</span>
|
||||
</a>
|
||||
<a href="/warehouse" class="nav-item {% if 'warehouse' in request.url.path %}active{% endif %}">
|
||||
<i class="material-icons">warehouse</i>
|
||||
<span>Magazzino</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="version-info">
|
||||
<span>v1.0.0</span>
|
||||
<span>In-Memory DB</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<header class="top-bar">
|
||||
<div class="top-bar-content">
|
||||
<h1 class="page-title">{% block page_title %}{% endblock %}</h1>
|
||||
<div class="top-bar-actions">
|
||||
<button class="btn-icon" title="Notifiche">
|
||||
<i class="material-icons">notifications</i>
|
||||
</button>
|
||||
<button class="btn-icon" title="Profilo">
|
||||
<i class="material-icons">account_circle</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="content-area">
|
||||
{% block content %}{% endblock %}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', path='/js/app.js') }}"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,136 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Clienti - Mago4 Demo{% endblock %}
|
||||
{% block page_title %}Clienti{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<button class="btn btn-primary" onclick="openCreateCustomerModal()">
|
||||
<i class="material-icons">add</i> Nuovo Cliente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="widget-card">
|
||||
<div class="widget-header">
|
||||
<h3 class="widget-title">Elenco Clienti</h3>
|
||||
</div>
|
||||
<div class="widget-body">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Codice</th>
|
||||
<th>Ragione Sociale</th>
|
||||
<th>Città</th>
|
||||
<th>Paese</th>
|
||||
<th>Telefono</th>
|
||||
<th>Limite Credito</th>
|
||||
<th>Saldo</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for customer in customers %}
|
||||
<tr>
|
||||
<td><strong>{{ customer.code }}</strong></td>
|
||||
<td>{{ customer.name }}</td>
|
||||
<td>{{ customer.city }}</td>
|
||||
<td>{{ customer.country }}</td>
|
||||
<td>{{ customer.phone }}</td>
|
||||
<td class="text-right">€ {{ "%.2f"|format(customer.credit_limit) }}</td>
|
||||
<td class="text-right {% if customer.balance > 0 %}text-danger{% endif %}">
|
||||
€ {{ "%.2f"|format(customer.balance) }}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn-small btn-info" onclick="viewCustomer({{ customer.id }})">
|
||||
<i class="material-icons">visibility</i>
|
||||
</button>
|
||||
<button class="btn-small btn-warning" onclick="editCustomer({{ customer.id }})">
|
||||
<i class="material-icons">edit</i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Detail -->
|
||||
<div id="customerModal" class="modal" onclick="closeModal(event)">
|
||||
<div class="modal-content" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">Dettagli Cliente</h2>
|
||||
<button class="btn-close" onclick="closeModal()">
|
||||
<i class="material-icons">close</i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- Content loaded via JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function viewCustomer(customerId) {
|
||||
fetch(`/api/customers/${customerId}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('modalTitle').textContent = `Cliente: ${data.name}`;
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Codice</label>
|
||||
<span>${data.code}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Ragione Sociale</label>
|
||||
<span>${data.name}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Email</label>
|
||||
<span>${data.email}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Telefono</label>
|
||||
<span>${data.phone}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Indirizzo</label>
|
||||
<span>${data.address}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Città</label>
|
||||
<span>${data.city}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Paese</label>
|
||||
<span>${data.country}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Limite Credito</label>
|
||||
<span>€ ${data.credit_limit.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Saldo</label>
|
||||
<span class="${data.balance > 0 ? 'text-danger' : ''}">${data.balance > 0 ? '€ ' : ''}${Math.abs(data.balance).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('customerModal').classList.add('show');
|
||||
});
|
||||
}
|
||||
|
||||
function editCustomer(customerId) {
|
||||
alert('Modifica cliente: ' + customerId + ' (da implementare)');
|
||||
}
|
||||
|
||||
function openCreateCustomerModal() {
|
||||
alert('Creazione nuovo cliente (da implementare)');
|
||||
}
|
||||
|
||||
function closeModal(event) {
|
||||
if (event && event.target.id !== 'customerModal') return;
|
||||
document.getElementById('customerModal').classList.remove('show');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,106 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - Mago4 Demo{% endblock %}
|
||||
{% block page_title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-container">
|
||||
<!-- First Row - KPI Cards -->
|
||||
<div class="widget-row">
|
||||
{% for widget in widgets[0] %}
|
||||
<div class="widget-card stat-card" style="border-left-color: var(--color-{{ widget.color }})">
|
||||
<div class="widget-header">
|
||||
<h3 class="widget-title">{{ widget.title }}</h3>
|
||||
</div>
|
||||
<div class="widget-body stat-body">
|
||||
<div class="stat-icon" style="color: var(--color-{{ widget.color }})">
|
||||
<i class="material-icons">{{ widget.icon }}</i>
|
||||
</div>
|
||||
<div class="stat-value">{{ widget.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Second Row - Data Tables -->
|
||||
<div class="widget-row">
|
||||
{% for widget in widgets[1] %}
|
||||
<div class="widget-card">
|
||||
<div class="widget-header">
|
||||
<h3 class="widget-title">{{ widget.title }}</h3>
|
||||
{% if widget.subtitle %}<p class="widget-subtitle">{{ widget.subtitle }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="widget-body grid-body">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if "Vendita" in widget.title %}
|
||||
<th>Data</th>
|
||||
<th>Numero Ordine</th>
|
||||
<th>Cliente</th>
|
||||
<th>Importo</th>
|
||||
<th>Stato</th>
|
||||
{% else %}
|
||||
<th>Data</th>
|
||||
<th>Numero Ordine</th>
|
||||
<th>Fornitore</th>
|
||||
<th>Importo</th>
|
||||
<th>Stato</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if "Vendita" in widget.title %}
|
||||
{% for order in widget.data.orders %}
|
||||
<tr>
|
||||
<td>{{ order.order_date }}</td>
|
||||
<td><strong>{{ order.order_number }}</strong></td>
|
||||
<td>{{ order.customer_name }}</td>
|
||||
<td class="text-right">{{ order.total_amount }}</td>
|
||||
<td>
|
||||
<span class="badge status-{{ order.status }}">{{ order.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for order in widget.data.orders %}
|
||||
<tr>
|
||||
<td>{{ order.order_date }}</td>
|
||||
<td><strong>{{ order.order_number }}</strong></td>
|
||||
<td>{{ order.supplier_name }}</td>
|
||||
<td class="text-right">{{ order.total_amount }}</td>
|
||||
<td>
|
||||
<span class="badge status-{{ order.status }}">{{ order.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="summary-stats">
|
||||
<div class="stat-summary">
|
||||
<div class="stat-label">Totale Clienti</div>
|
||||
<div class="stat-number">{{ stats.total_customers }}</div>
|
||||
</div>
|
||||
<div class="stat-summary">
|
||||
<div class="stat-label">Totale Fornitori</div>
|
||||
<div class="stat-number">{{ stats.total_suppliers }}</div>
|
||||
</div>
|
||||
<div class="stat-summary">
|
||||
<div class="stat-label">Articoli in Catalogo</div>
|
||||
<div class="stat-number">{{ stats.total_products }}</div>
|
||||
</div>
|
||||
<div class="stat-summary">
|
||||
<div class="stat-label">Debiti Fornitori</div>
|
||||
<div class="stat-number text-orange">€ {{ "%.2f"|format(stats.total_payable) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,144 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Articoli - Mago4 Demo{% endblock %}
|
||||
{% block page_title %}Articoli{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<button class="btn btn-primary" onclick="openCreateProductModal()">
|
||||
<i class="material-icons">add</i> Nuovo Articolo
|
||||
</button>
|
||||
{% if low_stock %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="material-icons">warning</i>
|
||||
{{ low_stock|length }} articolo/i sotto la soglia di riordino
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="widget-card">
|
||||
<div class="widget-header">
|
||||
<h3 class="widget-title">Catalogo Articoli</h3>
|
||||
</div>
|
||||
<div class="widget-body">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Codice</th>
|
||||
<th>Descrizione</th>
|
||||
<th>Categoria</th>
|
||||
<th>Prezzo Unitario</th>
|
||||
<th>Giacenza</th>
|
||||
<th>Soglia Riordino</th>
|
||||
<th>Fornitore</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for product in products %}
|
||||
<tr class="{% if product.quantity_in_stock <= product.reorder_level %}low-stock{% endif %}">
|
||||
<td><strong>{{ product.code }}</strong></td>
|
||||
<td>{{ product.description }}</td>
|
||||
<td>{{ product.category }}</td>
|
||||
<td class="text-right">€ {{ "%.2f"|format(product.unit_price) }}</td>
|
||||
<td class="text-right {% if product.quantity_in_stock <= product.reorder_level %}text-danger{% endif %}">
|
||||
{{ product.quantity_in_stock }}
|
||||
{% if product.quantity_in_stock <= product.reorder_level %}
|
||||
<i class="material-icons small-icon">warning</i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">{{ product.reorder_level }}</td>
|
||||
<td>
|
||||
{% if product.supplier_id %}
|
||||
Fornitore #{{ product.supplier_id }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn-small btn-info" onclick="viewProduct({{ product.id }})">
|
||||
<i class="material-icons">visibility</i>
|
||||
</button>
|
||||
<button class="btn-small btn-warning" onclick="editProduct({{ product.id }})">
|
||||
<i class="material-icons">edit</i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="productModal" class="modal" onclick="closeModal(event)">
|
||||
<div class="modal-content" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">Dettagli Articolo</h2>
|
||||
<button class="btn-close" onclick="closeModal()">
|
||||
<i class="material-icons">close</i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function viewProduct(productId) {
|
||||
fetch(`/api/products/${productId}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const isLowStock = data.quantity_in_stock <= data.reorder_level;
|
||||
document.getElementById('modalTitle').textContent = `Articolo: ${data.code}`;
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Codice</label>
|
||||
<span>${data.code}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Descrizione</label>
|
||||
<span>${data.description}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Categoria</label>
|
||||
<span>${data.category}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Prezzo Unitario</label>
|
||||
<span>€ ${data.unit_price.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Giacenza Attuale</label>
|
||||
<span class="${isLowStock ? 'text-danger' : ''}">
|
||||
${data.quantity_in_stock} ${isLowStock ? '⚠️ SOTTOSORTA' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Soglia Riordino</label>
|
||||
<span>${data.reorder_level}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Fornitore</label>
|
||||
<span>${data.supplier_id ? 'Fornitore #' + data.supplier_id : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('productModal').classList.add('show');
|
||||
});
|
||||
}
|
||||
|
||||
function editProduct(productId) {
|
||||
alert('Modifica articolo: ' + productId + ' (da implementare)');
|
||||
}
|
||||
|
||||
function openCreateProductModal() {
|
||||
alert('Creazione nuovo articolo (da implementare)');
|
||||
}
|
||||
|
||||
function closeModal(event) {
|
||||
if (event && event.target.id !== 'productModal') return;
|
||||
document.getElementById('productModal').classList.remove('show');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,145 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Ordini Acquisto - Mago4 Demo{% endblock %}
|
||||
{% block page_title %}Ordini Acquisto{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<button class="btn btn-primary" onclick="openCreateOrderModal()">
|
||||
<i class="material-icons">add</i> Nuovo Ordine
|
||||
</button>
|
||||
{% if pending %}
|
||||
<div class="alert alert-info">
|
||||
<i class="material-icons">info</i>
|
||||
{{ pending|length }} ordine/i in sospeso
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="widget-card">
|
||||
<div class="widget-header">
|
||||
<h3 class="widget-title">Elenco Ordini Acquisto</h3>
|
||||
</div>
|
||||
<div class="widget-body">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Numero Ordine</th>
|
||||
<th>Fornitore</th>
|
||||
<th>Importo Totale</th>
|
||||
<th>Data Consegna Prevista</th>
|
||||
<th>Stato</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td>{{ order.order_date.strftime("%d/%m/%Y") }}</td>
|
||||
<td><strong>{{ order.order_number }}</strong></td>
|
||||
<td>{{ order.supplier_name }}</td>
|
||||
<td class="text-right">€ {{ "%.2f"|format(order.total_amount) }}</td>
|
||||
<td>
|
||||
{% if order.expected_delivery_date %}
|
||||
{{ order.expected_delivery_date.strftime("%d/%m/%Y") }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge status-{{ order.status.value }}">{{ order.status.value }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn-small btn-info" onclick="viewOrder({{ order.id }})">
|
||||
<i class="material-icons">visibility</i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="orderModal" class="modal" onclick="closeModal(event)">
|
||||
<div class="modal-content" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">Dettagli Ordine Acquisto</h2>
|
||||
<button class="btn-close" onclick="closeModal()">
|
||||
<i class="material-icons">close</i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function viewOrder(orderId) {
|
||||
fetch(`/api/purchase-orders/${orderId}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const deliveryDate = data.expected_delivery_date ? new Date(data.expected_delivery_date).toLocaleDateString('it-IT') : '—';
|
||||
const orderDate = new Date(data.order_date).toLocaleDateString('it-IT');
|
||||
|
||||
let linesHtml = '<table class="data-table-nested"><thead><tr><th>Articolo</th><th>Quantità</th><th>Prezzo Unit.</th><th>Totale</th></tr></thead><tbody>';
|
||||
data.lines.forEach(line => {
|
||||
linesHtml += `<tr>
|
||||
<td>${line.product_description} (${line.product_code})</td>
|
||||
<td class="text-right">${line.quantity}</td>
|
||||
<td class="text-right">€ ${line.unit_price.toFixed(2)}</td>
|
||||
<td class="text-right">€ ${line.total_amount.toFixed(2)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
linesHtml += '</tbody></table>';
|
||||
|
||||
document.getElementById('modalTitle').textContent = `Ordine: ${data.order_number}`;
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Numero Ordine</label>
|
||||
<span>${data.order_number}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Data Ordine</label>
|
||||
<span>${orderDate}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Fornitore</label>
|
||||
<span>${data.supplier_name}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Stato</label>
|
||||
<span class="badge status-${data.status}">
|
||||
${data.status.replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Data Consegna Prevista</label>
|
||||
<span>${deliveryDate}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Importo Totale</label>
|
||||
<span class="text-orange" style="font-size: 1.2em; font-weight: bold;">€ ${data.total_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 20px;">
|
||||
<h4>Righe Ordine</h4>
|
||||
${linesHtml}
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('orderModal').classList.add('show');
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateOrderModal() {
|
||||
alert('Creazione nuovo ordine (da implementare)');
|
||||
}
|
||||
|
||||
function closeModal(event) {
|
||||
if (event && event.target.id !== 'orderModal') return;
|
||||
document.getElementById('orderModal').classList.remove('show');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,145 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Ordini Vendita - Mago4 Demo{% endblock %}
|
||||
{% block page_title %}Ordini Vendita{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<button class="btn btn-primary" onclick="openCreateOrderModal()">
|
||||
<i class="material-icons">add</i> Nuovo Ordine
|
||||
</button>
|
||||
{% if pending %}
|
||||
<div class="alert alert-info">
|
||||
<i class="material-icons">info</i>
|
||||
{{ pending|length }} ordine/i in sospeso
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="widget-card">
|
||||
<div class="widget-header">
|
||||
<h3 class="widget-title">Elenco Ordini Vendita</h3>
|
||||
</div>
|
||||
<div class="widget-body">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Numero Ordine</th>
|
||||
<th>Cliente</th>
|
||||
<th>Importo Totale</th>
|
||||
<th>Data Consegna</th>
|
||||
<th>Stato</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td>{{ order.order_date.strftime("%d/%m/%Y") }}</td>
|
||||
<td><strong>{{ order.order_number }}</strong></td>
|
||||
<td>{{ order.customer_name }}</td>
|
||||
<td class="text-right">€ {{ "%.2f"|format(order.total_amount) }}</td>
|
||||
<td>
|
||||
{% if order.delivery_date %}
|
||||
{{ order.delivery_date.strftime("%d/%m/%Y") }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge status-{{ order.status.value }}">{{ order.status.value }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn-small btn-info" onclick="viewOrder({{ order.id }})">
|
||||
<i class="material-icons">visibility</i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="orderModal" class="modal" onclick="closeModal(event)">
|
||||
<div class="modal-content" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">Dettagli Ordine Vendita</h2>
|
||||
<button class="btn-close" onclick="closeModal()">
|
||||
<i class="material-icons">close</i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function viewOrder(orderId) {
|
||||
fetch(`/api/sales-orders/${orderId}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const deliveryDate = data.delivery_date ? new Date(data.delivery_date).toLocaleDateString('it-IT') : '—';
|
||||
const orderDate = new Date(data.order_date).toLocaleDateString('it-IT');
|
||||
|
||||
let linesHtml = '<table class="data-table-nested"><thead><tr><th>Articolo</th><th>Quantità</th><th>Prezzo Unit.</th><th>Totale</th></tr></thead><tbody>';
|
||||
data.lines.forEach(line => {
|
||||
linesHtml += `<tr>
|
||||
<td>${line.product_description} (${line.product_code})</td>
|
||||
<td class="text-right">${line.quantity}</td>
|
||||
<td class="text-right">€ ${line.unit_price.toFixed(2)}</td>
|
||||
<td class="text-right">€ ${line.total_amount.toFixed(2)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
linesHtml += '</tbody></table>';
|
||||
|
||||
document.getElementById('modalTitle').textContent = `Ordine: ${data.order_number}`;
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Numero Ordine</label>
|
||||
<span>${data.order_number}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Data Ordine</label>
|
||||
<span>${orderDate}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Cliente</label>
|
||||
<span>${data.customer_name}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Stato</label>
|
||||
<span class="badge status-${data.status}">
|
||||
${data.status.replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Data Consegna Prevista</label>
|
||||
<span>${deliveryDate}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Importo Totale</label>
|
||||
<span class="text-success" style="font-size: 1.2em; font-weight: bold;">€ ${data.total_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 20px;">
|
||||
<h4>Righe Ordine</h4>
|
||||
${linesHtml}
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('orderModal').classList.add('show');
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateOrderModal() {
|
||||
alert('Creazione nuovo ordine (da implementare)');
|
||||
}
|
||||
|
||||
function closeModal(event) {
|
||||
if (event && event.target.id !== 'orderModal') return;
|
||||
document.getElementById('orderModal').classList.remove('show');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,134 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Fornitori - Mago4 Demo{% endblock %}
|
||||
{% block page_title %}Fornitori{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<button class="btn btn-primary" onclick="openCreateSupplierModal()">
|
||||
<i class="material-icons">add</i> Nuovo Fornitore
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="widget-card">
|
||||
<div class="widget-header">
|
||||
<h3 class="widget-title">Elenco Fornitori</h3>
|
||||
</div>
|
||||
<div class="widget-body">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Codice</th>
|
||||
<th>Ragione Sociale</th>
|
||||
<th>Città</th>
|
||||
<th>Paese</th>
|
||||
<th>Telefono</th>
|
||||
<th>Termini Pagamento</th>
|
||||
<th>Saldo</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for supplier in suppliers %}
|
||||
<tr>
|
||||
<td><strong>{{ supplier.code }}</strong></td>
|
||||
<td>{{ supplier.name }}</td>
|
||||
<td>{{ supplier.city }}</td>
|
||||
<td>{{ supplier.country }}</td>
|
||||
<td>{{ supplier.phone }}</td>
|
||||
<td class="text-center">{{ supplier.payment_terms_days }} gg</td>
|
||||
<td class="text-right {% if supplier.balance > 0 %}text-warning{% endif %}">
|
||||
€ {{ "%.2f"|format(supplier.balance) }}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn-small btn-info" onclick="viewSupplier({{ supplier.id }})">
|
||||
<i class="material-icons">visibility</i>
|
||||
</button>
|
||||
<button class="btn-small btn-warning" onclick="editSupplier({{ supplier.id }})">
|
||||
<i class="material-icons">edit</i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="supplierModal" class="modal" onclick="closeModal(event)">
|
||||
<div class="modal-content" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">Dettagli Fornitore</h2>
|
||||
<button class="btn-close" onclick="closeModal()">
|
||||
<i class="material-icons">close</i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function viewSupplier(supplierId) {
|
||||
fetch(`/api/suppliers/${supplierId}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('modalTitle').textContent = `Fornitore: ${data.name}`;
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Codice</label>
|
||||
<span>${data.code}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Ragione Sociale</label>
|
||||
<span>${data.name}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Email</label>
|
||||
<span>${data.email}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Telefono</label>
|
||||
<span>${data.phone}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Indirizzo</label>
|
||||
<span>${data.address}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Città</label>
|
||||
<span>${data.city}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Paese</label>
|
||||
<span>${data.country}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Termini Pagamento</label>
|
||||
<span>${data.payment_terms_days} giorni</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Saldo Debito</label>
|
||||
<span class="${data.balance > 0 ? 'text-warning' : ''}">€ ${data.balance.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('supplierModal').classList.add('show');
|
||||
});
|
||||
}
|
||||
|
||||
function editSupplier(supplierId) {
|
||||
alert('Modifica fornitore: ' + supplierId + ' (da implementare)');
|
||||
}
|
||||
|
||||
function openCreateSupplierModal() {
|
||||
alert('Creazione nuovo fornitore (da implementare)');
|
||||
}
|
||||
|
||||
function closeModal(event) {
|
||||
if (event && event.target.id !== 'supplierModal') return;
|
||||
document.getElementById('supplierModal').classList.remove('show');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,143 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Magazzino - Mago4 Demo{% endblock %}
|
||||
{% block page_title %}Magazzino{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
{% if low_stock %}
|
||||
<div class="alert alert-danger" style="flex: 1;">
|
||||
<i class="material-icons">error</i>
|
||||
<strong>Attenzione!</strong> {{ low_stock|length }} articolo/i in sottosorta
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="widget-row" style="margin-bottom: 20px;">
|
||||
<div class="widget-card stat-card" style="border-left-color: var(--color-blue)">
|
||||
<div class="widget-header">
|
||||
<h3 class="widget-title">Articoli in Catalogo</h3>
|
||||
</div>
|
||||
<div class="widget-body stat-body">
|
||||
<div class="stat-value">{{ products|length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="widget-card stat-card" style="border-left-color: var(--color-red)">
|
||||
<div class="widget-header">
|
||||
<h3 class="widget-title">Articoli in Sottosorta</h3>
|
||||
</div>
|
||||
<div class="widget-body stat-body">
|
||||
<div class="stat-icon" style="color: var(--color-red)">
|
||||
<i class="material-icons">warning</i>
|
||||
</div>
|
||||
<div class="stat-value">{{ low_stock|length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Low Stock Alert -->
|
||||
{% if low_stock %}
|
||||
<div class="widget-card">
|
||||
<div class="widget-header">
|
||||
<h3 class="widget-title">
|
||||
<i class="material-icons">warning</i> Articoli Sotto Soglia Riordino
|
||||
</h3>
|
||||
</div>
|
||||
<div class="widget-body">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Codice</th>
|
||||
<th>Descrizione</th>
|
||||
<th>Giacenza</th>
|
||||
<th>Soglia Riordino</th>
|
||||
<th>Scostamento</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for product in low_stock %}
|
||||
<tr class="highlight-danger">
|
||||
<td><strong>{{ product.code }}</strong></td>
|
||||
<td>{{ product.description }}</td>
|
||||
<td class="text-danger text-bold">{{ product.quantity_in_stock }}</td>
|
||||
<td class="text-right">{{ product.reorder_level }}</td>
|
||||
<td class="text-right text-danger">
|
||||
-{{ product.reorder_level - product.quantity_in_stock }} unità
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn-small btn-primary" onclick="createPOForProduct({{ product.id }})">
|
||||
<i class="material-icons">add_shopping_cart</i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- All Products -->
|
||||
<div class="widget-card">
|
||||
<div class="widget-header">
|
||||
<h3 class="widget-title">Inventario Completo</h3>
|
||||
</div>
|
||||
<div class="widget-body">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Codice</th>
|
||||
<th>Descrizione</th>
|
||||
<th>Categoria</th>
|
||||
<th>Giacenza</th>
|
||||
<th>Soglia Riordino</th>
|
||||
<th>Prezzo Unit.</th>
|
||||
<th>Valore Magazzino</th>
|
||||
<th>Stato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for product in products %}
|
||||
<tr class="{% if product.quantity_in_stock <= product.reorder_level %}low-stock{% endif %}">
|
||||
<td><strong>{{ product.code }}</strong></td>
|
||||
<td>{{ product.description }}</td>
|
||||
<td>{{ product.category }}</td>
|
||||
<td class="text-right {% if product.quantity_in_stock <= product.reorder_level %}text-danger text-bold{% endif %}">
|
||||
{{ product.quantity_in_stock }}
|
||||
</td>
|
||||
<td class="text-right">{{ product.reorder_level }}</td>
|
||||
<td class="text-right">€ {{ "%.2f"|format(product.unit_price) }}</td>
|
||||
<td class="text-right text-bold">
|
||||
€ {{ "%.2f"|format(product.quantity_in_stock * product.unit_price) }}
|
||||
</td>
|
||||
<td>
|
||||
{% if product.quantity_in_stock <= product.reorder_level %}
|
||||
<span class="badge badge-danger">
|
||||
<i class="material-icons">warning</i> SOTTOSORTA
|
||||
</span>
|
||||
{% elif product.quantity_in_stock <= product.reorder_level + 20 %}
|
||||
<span class="badge badge-warning">
|
||||
<i class="material-icons">info</i> PROSSIMA SOGLIA
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">
|
||||
<i class="material-icons">check</i> OK
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function createPOForProduct(productId) {
|
||||
alert('Creazione ordine di acquisto per articolo #' + productId + ' (da implementare)');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user