Initial commit — 3D Pricing app

This commit is contained in:
2026-07-06 11:37:27 +02:00
commit 1f653d8160
16 changed files with 1563 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Crée le dossier de données
RUN mkdir -p /data
# Lance l'init DB et le serveur
CMD ["sh", "-c", "python -c 'from app import init_db; init_db()' && python app.py"]
EXPOSE 5000
Binary file not shown.
+449
View File
@@ -0,0 +1,449 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, Response
import sqlite3, csv, io, os, zipfile, re
app = Flask(__name__)
DATABASE = os.environ.get('DATABASE_PATH', '/data/pricing.db')
def get_db():
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
return conn
def init_db():
os.makedirs(os.path.dirname(DATABASE), exist_ok=True)
conn = get_db()
conn.executescript('''
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, email TEXT DEFAULT '',
design_multiplier REAL DEFAULT 1.80, notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS materials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, brand TEXT DEFAULT '', type TEXT DEFAULT 'PLA',
color TEXT DEFAULT '', price_per_kg REAL NOT NULL DEFAULT 25.0,
stock_g REAL DEFAULT 0, low_stock_threshold_g REAL DEFAULT 200,
notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS material_price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
price_per_kg REAL NOT NULL, recorded_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
material_id INTEGER REFERENCES materials(id) ON DELETE SET NULL,
source_file TEXT DEFAULT '', name TEXT NOT NULL,
description TEXT DEFAULT '', weight_g REAL NOT NULL,
print_time_s INTEGER NOT NULL, design_multiplier REAL NOT NULL,
gross_margin_pct REAL NOT NULL DEFAULT 27.5, discount_pct REAL DEFAULT 0,
material_cost REAL, material_margin REAL, design_cost REAL,
handling_cost REAL, wear_cost REAL, electricity_cost REAL,
subtotal REAL, margin_amount REAL, tax_amount REAL, final_price REAL,
notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now'))
);
''')
defaults = [
('filament_price_per_kg','25.0'),('electricity_price_kwh','0.18'),
('printer_price','400.0'),('printer_lifespan_hours','5000'),
('nozzle_price','10.0'),('nozzle_lifespan_hours','250'),
('plate_price','25.0'),('plate_lifespan_hours','1000'),
('handling_rate_per_hour','40.0'),('handling_minutes_per_plate','5'),
('material_margin_pct','20.0'),('tax_rate_pct','12.3'),('printer_power_kw','0.35'),
]
conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults)
conn.commit(); conn.close()
def get_settings():
conn = get_db()
rows = conn.execute('SELECT key,value FROM settings').fetchall()
conn.close()
return {r['key']: float(r['value']) for r in rows}
def parse_3mf(file_stream):
result = {'weight_g': None, 'hours': None, 'minutes': None, 'filename': None}
try:
with zipfile.ZipFile(file_stream) as z:
for name in z.namelist():
if not any(name.endswith(e) for e in ['.xml','.model','.config','.json','.txt']):
continue
try:
raw = z.read(name).decode('utf-8', errors='ignore')
except Exception:
continue
if result['weight_g'] is None:
for pat in [
r'filament[_\s]*weight[^>]*?["\s](\d+[\.,]\d+)',
r'(\d+[\.,]\d+)\s*g\b(?!\s*[/])',
r'"weight"\s*[:=]\s*"?(\d+[\.,]\d+)',
r'used\s*\[g\][^"]*"\s*value\s*=\s*"(\d+[\.,]\d+)',
]:
m = re.search(pat, raw, re.IGNORECASE)
if m:
try:
result['weight_g'] = float(m.group(1).replace(',','.'))
break
except ValueError:
pass
if result['hours'] is None:
for pat in [
r'(\d+)h\s*(\d+)m',
r'(\d+)\s*hours?\s*(\d+)\s*min',
r'printing.time[^"]*"\s*value\s*=\s*"(\d+)h\s*(\d+)',
]:
m = re.search(pat, raw, re.IGNORECASE)
if m:
try:
h, mn = int(m.group(1)), int(m.group(2))
if 0 <= h <= 500 and 0 <= mn <= 59:
result['hours'] = h
result['minutes'] = mn
break
except (ValueError, IndexError):
pass
except Exception:
pass
return result
def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct,
price_per_kg_override=None, s=None):
if s is None:
s = get_settings()
price_kg = price_per_kg_override if price_per_kg_override is not None else s['filament_price_per_kg']
mat = (price_kg / 1000) * weight_g
mat_margin = mat * (s['material_margin_pct'] / 100)
design = mat * design_mult
handling = (s['handling_rate_per_hour'] / 60) * s['handling_minutes_per_plate']
printer_w = (s['printer_price'] / (s['printer_lifespan_hours'] * 3600)) * print_time_s
nozzle_w = (s['nozzle_price'] / (s['nozzle_lifespan_hours'] * 3600)) * print_time_s
plate_w = (s['plate_price'] / (s['plate_lifespan_hours'] * 3600)) * print_time_s
wear = printer_w + nozzle_w + plate_w
elec = s['printer_power_kw'] * (print_time_s / 3600) * s['electricity_price_kwh']
subtotal = mat + mat_margin + design + handling + wear + elec
margin = subtotal * (gross_margin_pct / 100)
pre_tax = subtotal + margin
tax_rate = s['tax_rate_pct'] / 100
with_tax = pre_tax / (1 - tax_rate)
tax_amt = with_tax - pre_tax
final = with_tax * (1 - discount_pct / 100)
return {
'material_cost': round(mat, 4),
'material_margin': round(mat_margin, 4),
'design_cost': round(design, 4),
'handling_cost': round(handling, 4),
'wear_cost': round(wear, 4),
'electricity_cost': round(elec, 4),
'subtotal': round(subtotal, 4),
'margin_amount': round(margin, 4),
'tax_amount': round(tax_amt, 4),
'final_price': round(final, 2),
}
def fmt_time(s):
return f"{s//3600}h{(s%3600)//60:02d}m"
app.jinja_env.filters['fmt_time'] = fmt_time
@app.route('/')
def dashboard():
conn = get_db()
recent = conn.execute('''
SELECT j.*, c.name as client_name, m.name as material_name
FROM jobs j LEFT JOIN clients c ON j.client_id=c.id
LEFT JOIN materials m ON j.material_id=m.id
ORDER BY j.created_at DESC LIMIT 10''').fetchall()
stats = conn.execute('''
SELECT COUNT(*) as total_jobs, ROUND(SUM(final_price),2) as total_revenue,
ROUND(AVG(gross_margin_pct),1) as avg_margin,
ROUND(SUM(CASE WHEN created_at>=date('now','-30 days') THEN final_price ELSE 0 END),2) as month_revenue
FROM jobs''').fetchone()
low_stock = conn.execute(
'SELECT * FROM materials WHERE stock_g<=low_stock_threshold_g ORDER BY stock_g ASC').fetchall()
conn.close()
return render_template('dashboard.html', jobs=recent, stats=stats, low_stock=low_stock)
@app.route('/clients')
def clients():
conn = get_db()
clients = conn.execute('''
SELECT c.*, COUNT(j.id) as job_count,
ROUND(COALESCE(SUM(j.final_price),0),2) as total_revenue
FROM clients c LEFT JOIN jobs j ON j.client_id=c.id
GROUP BY c.id ORDER BY c.name''').fetchall()
conn.close()
return render_template('clients.html', clients=clients)
@app.route('/clients/new', methods=['GET','POST'])
def new_client():
if request.method == 'POST':
conn = get_db()
conn.execute('INSERT INTO clients(name,email,design_multiplier,notes) VALUES(?,?,?,?)',
(request.form['name'], request.form.get('email',''),
float(request.form['design_multiplier']), request.form.get('notes','')))
conn.commit(); conn.close()
return redirect(url_for('clients'))
return render_template('client_form.html', client=None)
@app.route('/clients/<int:id>/edit', methods=['GET','POST'])
def edit_client(id):
conn = get_db()
client = conn.execute('SELECT * FROM clients WHERE id=?',(id,)).fetchone()
if request.method == 'POST':
conn.execute('UPDATE clients SET name=?,email=?,design_multiplier=?,notes=? WHERE id=?',
(request.form['name'], request.form.get('email',''),
float(request.form['design_multiplier']), request.form.get('notes',''), id))
conn.commit(); conn.close()
return redirect(url_for('clients'))
conn.close()
return render_template('client_form.html', client=client)
@app.route('/clients/<int:id>/delete', methods=['POST'])
def delete_client(id):
conn = get_db()
conn.execute('DELETE FROM clients WHERE id=?',(id,))
conn.commit(); conn.close()
return redirect(url_for('clients'))
@app.route('/materials')
def materials():
conn = get_db()
mats = conn.execute('''
SELECT m.*, COUNT(j.id) as job_count,
ROUND(COALESCE(SUM(j.weight_g),0),1) as total_used_g
FROM materials m LEFT JOIN jobs j ON j.material_id=m.id
GROUP BY m.id ORDER BY m.type, m.name''').fetchall()
conn.close()
return render_template('materials.html', materials=mats)
@app.route('/materials/new', methods=['GET','POST'])
def new_material():
if request.method == 'POST':
price = float(request.form['price_per_kg'])
conn = get_db()
conn.execute('''INSERT INTO materials(name,brand,type,color,price_per_kg,stock_g,low_stock_threshold_g,notes)
VALUES(?,?,?,?,?,?,?,?)''',
(request.form['name'], request.form.get('brand',''), request.form.get('type','PLA'),
request.form.get('color',''), price, float(request.form.get('stock_g',0)),
float(request.form.get('low_stock_threshold_g',200)), request.form.get('notes','')))
mat_id = conn.execute('SELECT last_insert_rowid()').fetchone()[0]
conn.execute('INSERT INTO material_price_history(material_id,price_per_kg) VALUES(?,?)', (mat_id, price))
conn.commit(); conn.close()
return redirect(url_for('materials'))
return render_template('material_form.html', material=None, history=None)
@app.route('/materials/<int:id>/edit', methods=['GET','POST'])
def edit_material(id):
conn = get_db()
mat = conn.execute('SELECT * FROM materials WHERE id=?',(id,)).fetchone()
history = conn.execute(
'SELECT * FROM material_price_history WHERE material_id=? ORDER BY recorded_at DESC LIMIT 10',
(id,)).fetchall()
if request.method == 'POST':
new_price = float(request.form['price_per_kg'])
old_price = mat['price_per_kg']
conn.execute('''UPDATE materials SET name=?,brand=?,type=?,color=?,price_per_kg=?,
stock_g=?,low_stock_threshold_g=?,notes=? WHERE id=?''',
(request.form['name'], request.form.get('brand',''), request.form.get('type','PLA'),
request.form.get('color',''), new_price, float(request.form.get('stock_g', mat['stock_g'])),
float(request.form.get('low_stock_threshold_g',200)), request.form.get('notes',''), id))
if abs(new_price - old_price) > 0.001:
conn.execute('INSERT INTO material_price_history(material_id,price_per_kg) VALUES(?,?)', (id, new_price))
conn.commit(); conn.close()
return redirect(url_for('materials'))
conn.close()
return render_template('material_form.html', material=mat, history=history)
@app.route('/materials/<int:id>/restock', methods=['POST'])
def restock_material(id):
conn = get_db()
add_g = float(request.form.get('add_g', 0))
conn.execute('UPDATE materials SET stock_g=stock_g+? WHERE id=?', (add_g, id))
new_price = request.form.get('new_price_per_kg')
if new_price:
new_price = float(new_price)
mat = conn.execute('SELECT price_per_kg FROM materials WHERE id=?',(id,)).fetchone()
conn.execute('UPDATE materials SET price_per_kg=? WHERE id=?', (new_price, id))
if abs(new_price - mat['price_per_kg']) > 0.001:
conn.execute('INSERT INTO material_price_history(material_id,price_per_kg) VALUES(?,?)', (id, new_price))
conn.commit(); conn.close()
return redirect(url_for('materials'))
@app.route('/materials/<int:id>/delete', methods=['POST'])
def delete_material(id):
conn = get_db()
conn.execute('DELETE FROM materials WHERE id=?',(id,))
conn.commit(); conn.close()
return redirect(url_for('materials'))
@app.route('/jobs')
def jobs():
conn = get_db()
jobs = conn.execute('''
SELECT j.*, c.name as client_name, m.name as material_name
FROM jobs j LEFT JOIN clients c ON j.client_id=c.id
LEFT JOIN materials m ON j.material_id=m.id
ORDER BY j.created_at DESC''').fetchall()
conn.close()
return render_template('jobs.html', jobs=jobs)
@app.route('/jobs/new', methods=['GET','POST'])
def new_job():
conn = get_db()
clients = conn.execute('SELECT * FROM clients ORDER BY name').fetchall()
mats = conn.execute('SELECT * FROM materials ORDER BY type,name').fetchall()
settings = get_settings()
if request.method == 'POST':
weight_g = float(request.form['weight_g'])
hours = int(request.form.get('hours',0))
minutes = int(request.form.get('minutes',0))
print_time_s = hours*3600 + minutes*60
design_mult = float(request.form['design_multiplier'])
gross_margin = float(request.form['gross_margin_pct'])
discount = float(request.form.get('discount_pct',0))
client_id = request.form.get('client_id') or None
material_id = request.form.get('material_id') or None
source_file = request.form.get('source_file','').strip()
price_kg = None
if material_id:
mat = conn.execute('SELECT price_per_kg FROM materials WHERE id=?',(material_id,)).fetchone()
if mat:
price_kg = mat['price_per_kg']
r = calc(weight_g, print_time_s, design_mult, gross_margin, discount, price_kg, settings)
conn.execute('''INSERT INTO jobs(client_id,material_id,source_file,name,description,
weight_g,print_time_s,design_multiplier,gross_margin_pct,discount_pct,
material_cost,material_margin,design_cost,handling_cost,wear_cost,
electricity_cost,subtotal,margin_amount,tax_amount,final_price,notes)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(client_id,material_id,source_file,request.form['name'],request.form.get('description',''),
weight_g,print_time_s,design_mult,gross_margin,discount,
r['material_cost'],r['material_margin'],r['design_cost'],r['handling_cost'],
r['wear_cost'],r['electricity_cost'],r['subtotal'],r['margin_amount'],
r['tax_amount'],r['final_price'],request.form.get('notes','')))
if material_id:
conn.execute('UPDATE materials SET stock_g=MAX(0,stock_g-?) WHERE id=?', (weight_g, material_id))
conn.commit(); conn.close()
return redirect(url_for('jobs'))
conn.close()
return render_template('new_job.html', clients=clients, materials=mats, settings=settings)
@app.route('/jobs/<int:id>')
def job_detail(id):
conn = get_db()
job = conn.execute('''
SELECT j.*, c.name as client_name,
m.name as material_name, m.color as material_color, m.type as material_type
FROM jobs j LEFT JOIN clients c ON j.client_id=c.id
LEFT JOIN materials m ON j.material_id=m.id
WHERE j.id=?''', (id,)).fetchone()
conn.close()
if not job:
return redirect(url_for('jobs'))
return render_template('job_detail.html', job=job)
@app.route('/jobs/<int:id>/delete', methods=['POST'])
def delete_job(id):
conn = get_db()
conn.execute('DELETE FROM jobs WHERE id=?',(id,))
conn.commit(); conn.close()
return redirect(url_for('jobs'))
@app.route('/api/calculate', methods=['POST'])
def api_calculate():
d = request.json
s = get_settings()
price_kg = None
if d.get('material_id'):
conn = get_db()
mat = conn.execute('SELECT price_per_kg FROM materials WHERE id=?',(d['material_id'],)).fetchone()
conn.close()
if mat:
price_kg = mat['price_per_kg']
result = calc(
weight_g=float(d['weight_g']),
print_time_s=int(d.get('hours',0))*3600 + int(d.get('minutes',0))*60,
design_mult=float(d['design_multiplier']),
gross_margin_pct=float(d['gross_margin_pct']),
discount_pct=float(d.get('discount_pct',0)),
price_per_kg_override=price_kg, s=s)
return jsonify(result)
@app.route('/api/clients/<int:id>')
def api_client(id):
conn = get_db()
c = conn.execute('SELECT * FROM clients WHERE id=?',(id,)).fetchone()
conn.close()
if not c:
return jsonify({}), 404
return jsonify({'design_multiplier': c['design_multiplier']})
@app.route('/api/parse-3mf', methods=['POST'])
def api_parse_3mf():
if 'file' not in request.files:
return jsonify({'error': 'Aucun fichier'}), 400
f = request.files['file']
if not f.filename.lower().endswith('.3mf'):
return jsonify({'error': 'Format .3mf requis'}), 400
result = parse_3mf(f.stream)
result['filename'] = os.path.splitext(f.filename)[0]
return jsonify(result)
@app.route('/settings', methods=['GET','POST'])
def settings():
if request.method == 'POST':
conn = get_db()
for key in request.form:
conn.execute('UPDATE settings SET value=? WHERE key=?', (request.form[key], key))
conn.commit(); conn.close()
return redirect(url_for('settings'))
return render_template('settings.html', settings=get_settings())
@app.route('/stats')
def stats():
conn = get_db()
by_client = conn.execute('''
SELECT COALESCE(c.name,'Sans client') as name, COUNT(j.id) as jobs,
ROUND(SUM(j.final_price),2) as revenue, ROUND(AVG(j.gross_margin_pct),1) as avg_margin
FROM jobs j LEFT JOIN clients c ON j.client_id=c.id
GROUP BY j.client_id ORDER BY revenue DESC''').fetchall()
by_month = conn.execute('''
SELECT strftime('%Y-%m',created_at) as month, COUNT(*) as jobs,
ROUND(SUM(final_price),2) as revenue
FROM jobs GROUP BY month ORDER BY month DESC LIMIT 12''').fetchall()
by_material = conn.execute('''
SELECT COALESCE(m.name,'Non defini') as name, m.type, COUNT(j.id) as jobs,
ROUND(SUM(j.weight_g),1) as total_g, ROUND(SUM(j.final_price),2) as revenue
FROM jobs j LEFT JOIN materials m ON j.material_id=m.id
GROUP BY j.material_id ORDER BY total_g DESC''').fetchall()
conn.close()
return render_template('stats.html', by_client=by_client, by_month=by_month, by_material=by_material)
@app.route('/export/csv')
def export_csv():
conn = get_db()
jobs = conn.execute('''
SELECT j.created_at, COALESCE(c.name,'') as client,
COALESCE(m.name,'') as material, m.type as material_type,
j.source_file, j.name, j.weight_g, j.print_time_s,
j.design_multiplier, j.gross_margin_pct, j.discount_pct,
j.material_cost, j.material_margin, j.design_cost, j.handling_cost,
j.wear_cost, j.electricity_cost, j.subtotal, j.margin_amount,
j.tax_amount, j.final_price
FROM jobs j LEFT JOIN clients c ON j.client_id=c.id
LEFT JOIN materials m ON j.material_id=m.id ORDER BY j.created_at DESC''').fetchall()
conn.close()
out = io.StringIO()
w = csv.writer(out, delimiter=';')
w.writerow(['Date','Client','Matiere','Type','Fichier source','Nom',
'Poids(g)','Temps(s)','Coeff Design','Marge(%)','Remise(%)',
'Matiere EUR','Marge Mat.','Design','Manutention','Usure',
'Electricite','Sous-total','Marge Brute','Cotisations','Prix Final'])
for j in jobs:
w.writerow(list(j))
return Response(out.getvalue(), mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=tarifs_3d.csv'})
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=5000, debug=False)
+26
View File
@@ -0,0 +1,26 @@
services:
3d-pricing:
build: .
container_name: 3d-pricing
restart: unless-stopped
volumes:
# Données persistantes (base SQLite)
- ./data:/data
environment:
- DATABASE_PATH=/data/pricing.db
labels:
# --- Traefik ---
- "traefik.enable=true"
# Remplacez pricing.votre-domaine.com par votre domaine réel
- "traefik.http.routers.3d-pricing.rule=Host(`pricing.votre-domaine.com`)"
- "traefik.http.routers.3d-pricing.entrypoints=websecure"
- "traefik.http.routers.3d-pricing.tls=true"
- "traefik.http.routers.3d-pricing.tls.certresolver=letsencrypt"
- "traefik.http.services.3d-pricing.loadbalancer.server.port=5000"
networks:
- traefik_proxy
networks:
traefik_proxy:
external: true
# Nom du réseau Traefik existant — ajustez si différent
+1
View File
@@ -0,0 +1 @@
flask>=3.0.0
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}3D Pricing{% endblock %} — 3D Pricing</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
:root { --sidebar-w: 220px; }
body { background: #f4f6f9; }
#sidebar {
width: var(--sidebar-w); min-height: 100vh;
background: #1a1d23; position: fixed; top: 0; left: 0;
display: flex; flex-direction: column;
}
#sidebar .brand {
padding: 1.25rem 1.25rem 1rem; color: #fff;
font-weight: 700; font-size: 1.1rem;
border-bottom: 1px solid #2e323d;
}
#sidebar .brand span { color: #ff6b35; }
#sidebar nav { flex: 1; padding: .5rem 0; }
#sidebar nav a {
display: flex; align-items: center; gap: .65rem;
padding: .6rem 1.25rem; color: #9ba3af;
text-decoration: none; font-size: .875rem;
transition: background .15s, color .15s;
}
#sidebar nav a:hover, #sidebar nav a.active { background: #2e323d; color: #fff; }
#sidebar nav a.active { border-left: 3px solid #ff6b35; }
#sidebar nav .nav-section {
padding: .75rem 1.25rem .25rem; font-size: .7rem;
text-transform: uppercase; letter-spacing: .08em; color: #4b5563;
}
#main { margin-left: var(--sidebar-w); padding: 1.75rem 2rem; }
.page-header { margin-bottom: 1.5rem; }
.page-header h1 { font-size: 1.4rem; font-weight: 700; margin: 0; }
.stat-card {
background: #fff; border-radius: 10px; padding: 1.25rem 1.5rem;
box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
.stat-card .label { font-size: .75rem; color: #6b7280; text-transform: uppercase; letter-spacing: .05em; }
.stat-card .value { font-size: 1.75rem; font-weight: 700; margin-top: .1rem; }
.card { border: none; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.card-header { background: #fff; border-bottom: 1px solid #f0f0f0; font-weight: 600; }
.breakdown-row { display: flex; justify-content: space-between; padding: .2rem 0; font-size: .88rem; }
.breakdown-row.total { font-weight: 700; font-size: 1rem; border-top: 2px solid #dee2e6; padding-top: .5rem; margin-top: .3rem; }
.breakdown-row.subtotal { border-top: 1px solid #dee2e6; padding-top: .4rem; margin-top: .2rem; }
.breakdown-muted { color: #6b7280; }
.final-price { font-size: 2rem; font-weight: 700; color: #ff6b35; }
.stock-ok { color: #22c55e; }
.stock-low { color: #f59e0b; }
.stock-empty { color: #ef4444; }
</style>
</head>
<body>
<div id="sidebar">
<div class="brand"><i class="bi bi-layers-half"></i> 3D<span>Pricing</span></div>
<nav>
<div class="nav-section">Navigation</div>
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
<a href="{{ url_for('jobs') }}" class="{% if request.endpoint in ['jobs','job_detail'] %}active{% endif %}">
<i class="bi bi-printer"></i> Commandes
</a>
<a href="{{ url_for('new_job') }}" class="{% if request.endpoint=='new_job' %}active{% endif %}">
<i class="bi bi-plus-circle"></i> Nouveau calcul
</a>
<a href="{{ url_for('clients') }}" class="{% if 'client' in request.endpoint %}active{% endif %}">
<i class="bi bi-building"></i> Clients
</a>
<a href="{{ url_for('materials') }}" class="{% if 'material' in request.endpoint %}active{% endif %}">
<i class="bi bi-boxes"></i> Matières
</a>
<div class="nav-section">Analyse</div>
<a href="{{ url_for('stats') }}" class="{% if request.endpoint=='stats' %}active{% endif %}">
<i class="bi bi-bar-chart-line"></i> Statistiques
</a>
<a href="{{ url_for('export_csv') }}">
<i class="bi bi-download"></i> Export CSV
</a>
<div class="nav-section">Config</div>
<a href="{{ url_for('settings') }}" class="{% if request.endpoint=='settings' %}active{% endif %}">
<i class="bi bi-sliders"></i> Paramètres
</a>
</nav>
</div>
<div id="main">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} alert-dismissible fade show">
{{ msg }} <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle
+64
View File
@@ -0,0 +1,64 @@
{% extends 'base.html' %}
{% block title %}{% if client %}Modifier{% else %}Nouveau{% endif %} client{% endblock %}
{% block content %}
<div class="page-header">
<a href="{{ url_for('clients') }}" class="text-muted text-decoration-none small">
<i class="bi bi-arrow-left"></i> Clients
</a>
<h1 class="mt-1">{% if client %}Modifier {{ client.name }}{% else %}Nouveau client{% endif %}</h1>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label class="form-label fw-semibold">Nom *</label>
<input type="text" name="name" class="form-control" required
value="{{ client.name if client else '' }}" placeholder="ex: Blockcorp">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Email</label>
<input type="email" name="email" class="form-control"
value="{{ client.email if client else '' }}">
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Coefficient design par défaut</label>
<div class="d-flex gap-2 mb-2">
<button type="button" class="btn btn-sm btn-outline-success" onclick="setDM(0.80)">
×0.80 — STL fourni
</button>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="setDM(1.80)">
×1.80 — Création complète
</button>
</div>
<input type="number" name="design_multiplier" id="dm" class="form-control"
step="0.05" min="0" value="{{ client.design_multiplier if client else '1.80' }}">
<div class="form-text">0.80 = client qui fournit ses STL. 1.80 = vous faites tout le design.</div>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Notes</label>
<textarea name="notes" class="form-control" rows="3">{{ client.notes if client else '' }}</textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn" style="background:#ff6b35;color:#fff">
<i class="bi bi-save me-1"></i>Enregistrer
</button>
<a href="{{ url_for('clients') }}" class="btn btn-outline-secondary">Annuler</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function setDM(val) {
document.getElementById('dm').value = val;
}
</script>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
{% extends 'base.html' %}
{% block title %}Clients{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<h1><i class="bi bi-building me-2 text-info"></i>Clients</h1>
<a href="{{ url_for('new_client') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">
<i class="bi bi-plus-lg me-1"></i>Nouveau client
</a>
</div>
<div class="card">
<div class="card-body p-0">
{% if clients %}
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Nom</th><th>Email</th><th>Coeff design par défaut</th>
<th>Commandes</th><th>CA total</th><th></th>
</tr>
</thead>
<tbody>
{% for c in clients %}
<tr>
<td class="fw-semibold">{{ c.name }}</td>
<td class="text-muted">{{ c.email or '—' }}</td>
<td>
{% if c.design_multiplier <= 0.80 %}
<span class="badge bg-success">×{{ c.design_multiplier }} (STL fourni)</span>
{% else %}
<span class="badge bg-primary">×{{ c.design_multiplier }}</span>
{% endif %}
</td>
<td>{{ c.job_count }}</td>
<td class="fw-semibold">{{ c.total_revenue or 0 }} €</td>
<td>
<div class="d-flex gap-1">
<a href="{{ url_for('edit_client', id=c.id) }}" class="btn btn-sm btn-outline-secondary py-0">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="{{ url_for('delete_client', id=c.id) }}"
onsubmit="return confirm('Supprimer ce client ? Ses commandes seront conservées.')">
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-building fs-1"></i>
<p class="mt-2">Aucun client enregistré.</p>
<a href="{{ url_for('new_client') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">Ajouter le premier client</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
Binary file not shown.
+102
View File
@@ -0,0 +1,102 @@
{% extends 'base.html' %}
{% block title %}{{ job.name }}{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<div>
<a href="{{ url_for('jobs') }}" class="text-muted text-decoration-none small">
<i class="bi bi-arrow-left"></i> Commandes
</a>
<h1 class="mt-1">{{ job.name }}</h1>
</div>
<form method="POST" action="{{ url_for('delete_job', id=job.id) }}"
onsubmit="return confirm('Supprimer cette commande ?')">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i> Supprimer</button>
</form>
</div>
<div class="row g-4">
<div class="col-md-5">
<div class="card mb-3">
<div class="card-header py-3">Informations</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><th class="text-muted fw-normal" style="width:45%">Client</th><td>{{ job.client_name or '—' }}</td></tr>
<tr><th class="text-muted fw-normal">Matière</th>
<td>
{% if job.material_name %}
<span class="badge bg-secondary">{{ job.material_type }}</span> {{ job.material_name }}
{% else %}—{% endif %}
</td>
</tr>
<tr><th class="text-muted fw-normal">Fichier source</th>
<td class="text-monospace small">{{ job.source_file or '—' }}</td></tr>
<tr><th class="text-muted fw-normal">Date</th><td>{{ job.created_at[:10] }}</td></tr>
<tr><th class="text-muted fw-normal">Poids filament</th><td>{{ job.weight_g }} g</td></tr>
<tr><th class="text-muted fw-normal">Durée impression</th><td>{{ job.print_time_s | fmt_time }}</td></tr>
<tr><th class="text-muted fw-normal">Coeff design</th><td>×{{ job.design_multiplier }}</td></tr>
<tr><th class="text-muted fw-normal">Marge brute</th><td>{{ job.gross_margin_pct }} %</td></tr>
<tr><th class="text-muted fw-normal">Remise</th><td>{{ job.discount_pct }} %</td></tr>
</table>
{% if job.notes %}
<hr>
<small class="text-muted">{{ job.notes }}</small>
{% endif %}
</div>
</div>
</div>
<div class="col-md-7">
<div class="card">
<div class="card-header py-3"><i class="bi bi-receipt me-2"></i>Décomposition du prix</div>
<div class="card-body">
<div class="breakdown-row"><span class="breakdown-muted">Matière</span><span>{{ "%.4f"|format(job.material_cost) }} €</span></div>
<div class="breakdown-row"><span class="breakdown-muted">Marge matière</span><span>{{ "%.4f"|format(job.material_margin) }} €</span></div>
<div class="breakdown-row"><span class="breakdown-muted">Design (×{{ job.design_multiplier }})</span><span>{{ "%.4f"|format(job.design_cost) }} €</span></div>
<div class="breakdown-row"><span class="breakdown-muted">Manutention</span><span>{{ "%.4f"|format(job.handling_cost) }} €</span></div>
<div class="breakdown-row"><span class="breakdown-muted">Usure machine</span><span>{{ "%.4f"|format(job.wear_cost) }} €</span></div>
<div class="breakdown-row"><span class="breakdown-muted">Électricité</span><span>{{ "%.4f"|format(job.electricity_cost) }} €</span></div>
<div class="breakdown-row subtotal">
<span class="fw-semibold">Sous-total coûts</span>
<span class="fw-semibold">{{ "%.2f"|format(job.subtotal) }} €</span>
</div>
<div class="breakdown-row">
<span class="breakdown-muted">Marge brute ({{ job.gross_margin_pct }}%)</span>
<span>{{ "%.2f"|format(job.margin_amount) }} €</span>
</div>
<div class="breakdown-row">
<span class="breakdown-muted">Cotisations micro-ent.</span>
<span>{{ "%.2f"|format(job.tax_amount) }} €</span>
</div>
{% if job.discount_pct > 0 %}
<div class="breakdown-row text-danger">
<span>Remise ({{ job.discount_pct }}%)</span>
<span>{{ "%.2f"|format((job.subtotal + job.margin_amount + job.tax_amount) * job.discount_pct / 100) }} €</span>
</div>
{% endif %}
<div class="breakdown-row total">
<span>PRIX FINAL</span>
<span class="final-price">{{ "%.2f"|format(job.final_price) }} €</span>
</div>
<!-- Barre de composition -->
{% set total = job.final_price %}
{% if total > 0 %}
<div class="mt-4">
<div class="d-flex justify-content-between small text-muted mb-1">
<span>Coûts ({{ (job.subtotal/total*100)|round(1) }}%)</span>
<span>Marge ({{ (job.margin_amount/total*100)|round(1) }}%)</span>
<span>Taxes ({{ (job.tax_amount/total*100)|round(1) }}%)</span>
</div>
<div class="progress" style="height:18px;border-radius:6px">
<div class="progress-bar bg-secondary" style="width:{{ (job.subtotal/total*100)|round(1) }}%"></div>
<div class="progress-bar" style="width:{{ (job.margin_amount/total*100)|round(1) }}%;background:#ff6b35"></div>
<div class="progress-bar bg-warning text-dark" style="width:{{ (job.tax_amount/total*100)|round(1) }}%"></div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
+66
View File
@@ -0,0 +1,66 @@
{% extends 'base.html' %}
{% block title %}Commandes{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<h1><i class="bi bi-printer me-2 text-primary"></i>Commandes</h1>
<div class="d-flex gap-2">
<a href="{{ url_for('export_csv') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-download me-1"></i>Export CSV
</a>
<a href="{{ url_for('new_job') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">
<i class="bi bi-plus-lg me-1"></i>Nouveau
</a>
</div>
</div>
<div class="card">
<div class="card-body p-0">
{% if jobs %}
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th><th>Nom</th><th>Client</th><th>Poids</th>
<th>Durée</th><th>Design</th><th>Marge</th><th>Remise</th>
<th class="text-end">Prix final</th><th></th>
</tr>
</thead>
<tbody>
{% for j in jobs %}
<tr>
<td class="text-muted small">{{ j.created_at[:10] }}</td>
<td><a href="{{ url_for('job_detail', id=j.id) }}" class="text-decoration-none fw-semibold">{{ j.name }}</a></td>
<td>{{ j.client_name or '—' }}</td>
<td>{{ j.weight_g }} g</td>
<td>{{ j.print_time_s | fmt_time }}</td>
<td><span class="badge bg-light text-dark">×{{ j.design_multiplier }}</span></td>
<td><span class="badge bg-light text-dark">{{ j.gross_margin_pct }}%</span></td>
<td>
{% if j.discount_pct > 0 %}
<span class="badge bg-danger">-{{ j.discount_pct }}%</span>
{% else %}—{% endif %}
</td>
<td class="text-end fw-bold" style="color:#ff6b35">{{ j.final_price }} €</td>
<td>
<div class="d-flex gap-1">
<a href="{{ url_for('job_detail', id=j.id) }}" class="btn btn-sm btn-outline-secondary py-0"></a>
<form method="POST" action="{{ url_for('delete_job', id=j.id) }}"
onsubmit="return confirm('Supprimer ?')">
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1"></i>
<p class="mt-2">Aucune commande enregistrée.</p>
<a href="{{ url_for('new_job') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">Créer le premier calcul</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+100
View File
@@ -0,0 +1,100 @@
{% extends 'base.html' %}
{% block title %}{% if material %}Modifier{% else %}Nouvelle{% endif %} matière{% endblock %}
{% block content %}
<div class="page-header">
<a href="{{ url_for('materials') }}" class="text-muted text-decoration-none small">
<i class="bi bi-arrow-left"></i> Matières
</a>
<h1 class="mt-1">{% if material %}Modifier — {{ material.name }}{% else %}Nouvelle matière{% endif %}</h1>
</div>
<div class="row g-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<form method="POST">
<div class="row g-3">
<div class="col-8">
<label class="form-label fw-semibold">Nom *</label>
<input type="text" name="name" class="form-control" required
value="{{ material.name if material else '' }}"
placeholder="ex: PLA+ Rouge Bambu">
</div>
<div class="col-4">
<label class="form-label fw-semibold">Type</label>
<select name="type" class="form-select">
{% for t in ['PLA','PLA+','PETG','ASA','ABS','TPU','Nylon','PC','Résine','Autre'] %}
<option value="{{ t }}" {% if material and material.type==t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="col-6">
<label class="form-label fw-semibold">Marque</label>
<input type="text" name="brand" class="form-control"
value="{{ material.brand if material else '' }}"
placeholder="Bambu, eSUN, Polymaker…">
</div>
<div class="col-6">
<label class="form-label fw-semibold">Couleur</label>
<input type="text" name="color" class="form-control"
value="{{ material.color if material else '' }}"
placeholder="Rouge, #FF0000…">
</div>
<div class="col-6">
<label class="form-label fw-semibold">Prix (€/kg) *</label>
<input type="number" name="price_per_kg" class="form-control"
step="0.01" min="0" required
value="{{ material.price_per_kg if material else '25.00' }}">
</div>
<div class="col-6">
<label class="form-label fw-semibold">Stock actuel (g)</label>
<input type="number" name="stock_g" class="form-control"
step="1" min="0"
value="{{ material.stock_g|round(0)|int if material else 0 }}">
<div class="form-text">1 rouleau ≈ 1000 g</div>
</div>
<div class="col-6">
<label class="form-label fw-semibold">Seuil alerte stock bas (g)</label>
<input type="number" name="low_stock_threshold_g" class="form-control"
step="50" min="0"
value="{{ material.low_stock_threshold_g|round(0)|int if material else 200 }}">
</div>
<div class="col-12">
<label class="form-label fw-semibold">Notes</label>
<textarea name="notes" class="form-control" rows="2">{{ material.notes if material else '' }}</textarea>
</div>
</div>
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn" style="background:#ff6b35;color:#fff">
<i class="bi bi-save me-1"></i>Enregistrer
</button>
<a href="{{ url_for('materials') }}" class="btn btn-outline-secondary">Annuler</a>
</div>
</form>
</div>
</div>
</div>
{% if history %}
<div class="col-md-4">
<div class="card">
<div class="card-header py-3"><i class="bi bi-clock-history me-2"></i>Historique des prix</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light"><tr><th>Date</th><th class="text-end">€/kg</th></tr></thead>
<tbody>
{% for h in history %}
<tr>
<td class="text-muted small">{{ h.recorded_at[:10] }}</td>
<td class="text-end fw-semibold">{{ h.price_per_kg }} €</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+120
View File
@@ -0,0 +1,120 @@
{% extends 'base.html' %}
{% block title %}Matières{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<h1><i class="bi bi-boxes me-2" style="color:#8b5cf6"></i>Matières & Stock</h1>
<a href="{{ url_for('new_material') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">
<i class="bi bi-plus-lg me-1"></i>Ajouter une matière
</a>
</div>
<div class="card">
<div class="card-body p-0">
{% if materials %}
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Nom</th><th>Type</th><th>Couleur</th>
<th>Prix/kg</th><th>Stock</th><th>Commandes</th><th>Utilisé total</th><th></th>
</tr>
</thead>
<tbody>
{% for m in materials %}
{% set stock_pct = [[(m.stock_g / 1000 * 100)|round(0)|int, 0]|max, 100]|min %}
<tr>
<td class="fw-semibold">{{ m.name }}{% if m.brand %} <small class="text-muted">/ {{ m.brand }}</small>{% endif %}</td>
<td><span class="badge bg-secondary">{{ m.type }}</span></td>
<td>
{% if m.color %}
<span class="d-inline-flex align-items-center gap-1">
<span style="width:14px;height:14px;border-radius:50%;background:{{ m.color }};border:1px solid #ccc;display:inline-block"></span>
{{ m.color }}
</span>
{% else %}—{% endif %}
</td>
<td class="fw-semibold">{{ m.price_per_kg }} €</td>
<td style="min-width:140px">
{% if m.stock_g <= 0 %}
<span class="stock-empty fw-bold"><i class="bi bi-x-circle"></i> Rupture</span>
{% elif m.stock_g <= m.low_stock_threshold_g %}
<div class="d-flex align-items-center gap-2">
<span class="stock-low"><i class="bi bi-exclamation-triangle"></i> {{ m.stock_g|round(0)|int }} g</span>
</div>
{% else %}
<span class="stock-ok"><i class="bi bi-check-circle"></i> {{ m.stock_g|round(0)|int }} g</span>
{% endif %}
<div class="progress mt-1" style="height:4px">
<div class="progress-bar
{% if m.stock_g <= 0 %}bg-danger
{% elif m.stock_g <= m.low_stock_threshold_g %}bg-warning
{% else %}bg-success{% endif %}"
style="width:{{ [[(m.stock_g / 1000 * 100)|round(0)|int, 0]|max, 100]|min }}%">
</div>
</div>
</td>
<td>{{ m.job_count }}</td>
<td>{{ m.total_used_g }} g</td>
<td>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-outline-success py-0" data-bs-toggle="modal"
data-bs-target="#restockModal{{ m.id }}">
<i class="bi bi-plus-lg"></i> Stock
</button>
<a href="{{ url_for('edit_material', id=m.id) }}" class="btn btn-sm btn-outline-secondary py-0">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="{{ url_for('delete_material', id=m.id) }}"
onsubmit="return confirm('Supprimer cette matière ?')">
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
<!-- Modal Restock -->
<div class="modal fade" id="restockModal{{ m.id }}" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<form method="POST" action="{{ url_for('restock_material', id=m.id) }}">
<div class="modal-header">
<h5 class="modal-title">Réapprovisionner — {{ m.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Quantité à ajouter (g)</label>
<input type="number" name="add_g" class="form-control" step="1" min="1"
placeholder="ex: 1000 pour 1 kg" required>
</div>
<div class="mb-3">
<label class="form-label">Nouveau prix/kg (optionnel)</label>
<input type="number" name="new_price_per_kg" class="form-control"
step="0.01" placeholder="{{ m.price_per_kg }} (actuel)">
<div class="form-text">Laissez vide si le prix n'a pas changé.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Annuler</button>
<button type="submit" class="btn btn-success btn-sm">Ajouter au stock</button>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-boxes fs-1"></i>
<p class="mt-2">Aucune matière enregistrée.</p>
<a href="{{ url_for('new_material') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">
Ajouter votre premier filament
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+233
View File
@@ -0,0 +1,233 @@
{% extends 'base.html' %}
{% block title %}Nouveau calcul{% endblock %}
{% block content %}
<div class="page-header">
<h1><i class="bi bi-plus-circle me-2" style="color:#ff6b35"></i>Nouveau calcul de prix</h1>
</div>
<div class="row g-4">
<div class="col-lg-7">
<!-- Import 3mf -->
<div class="card mb-3 border border-warning border-opacity-50">
<div class="card-header py-3">
<i class="bi bi-file-earmark-zip text-warning me-2"></i>Import Bambu Studio (.3mf)
<span class="badge bg-warning text-dark ms-1">Optionnel</span>
</div>
<div class="card-body">
<div class="d-flex align-items-center gap-3">
<input type="file" id="file3mf" accept=".3mf" class="form-control" style="max-width:320px">
<button type="button" class="btn btn-outline-warning" onclick="parse3mf()">
<i class="bi bi-magic me-1"></i>Extraire
</button>
<span id="parseStatus" class="text-muted small"></span>
</div>
<div class="form-text mt-1">Glissez votre fichier .3mf depuis Bambu Studio pour remplir poids et duree automatiquement.</div>
</div>
</div>
<form method="POST" id="jobForm">
<div class="card mb-3">
<div class="card-header py-3">Donnees impression</div>
<div class="card-body">
<div class="row g-3 mb-3">
<div class="col-md-8">
<label class="form-label fw-semibold">Nom de la commande</label>
<input type="text" name="name" id="jobName" class="form-control" required placeholder="ex: Marqueurs competition salle X">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Fichier source</label>
<input type="text" name="source_file" id="sourceFile" class="form-control" placeholder="nom_fichier.3mf">
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Client</label>
<select name="client_id" id="clientSelect" class="form-select">
<option value="">Sans client</option>
{% for c in clients %}
<option value="{{ c.id }}" data-dm="{{ c.design_multiplier }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Matiere</label>
<select name="material_id" id="materialSelect" class="form-select">
<option value="">Prix global (parametres)</option>
{% for m in materials %}
<option value="{{ m.id }}" data-price="{{ m.price_per_kg }}" data-stock="{{ m.stock_g }}" data-threshold="{{ m.low_stock_threshold_g }}">
{{ m.name }} ({{ m.type }}) - {{ m.price_per_kg }}EUR/kg
{% if m.stock_g <= 0 %}[RUPTURE]{% elif m.stock_g <= m.low_stock_threshold_g %}[stock bas]{% endif %}
</option>
{% endfor %}
</select>
<div id="stockInfo" class="form-text"></div>
</div>
</div>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label fw-semibold">Poids filament (g)</label>
<input type="number" name="weight_g" id="weight_g" class="form-control" step="0.01" min="0.1" required placeholder="423.97">
<div class="form-text">Champ Modele dans Bambu</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Heures</label>
<input type="number" name="hours" id="hours" class="form-control" min="0" value="0">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Minutes</label>
<input type="number" name="minutes" id="minutes" class="form-control" min="0" max="59" value="0">
<div class="form-text">Temps total Bambu</div>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header py-3">Parametres tarifaires</div>
<div class="card-body">
<div class="mb-4">
<label class="form-label fw-semibold">
Coefficient design <span class="badge bg-secondary ms-1" id="dmLabel">x1.80</span>
</label>
<div class="d-flex gap-2 mb-2">
<button type="button" class="btn btn-sm btn-outline-success" onclick="setDM(0.80)">STL fourni (x0.80)</button>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="setDM(1.80)">Creation complete (x1.80)</button>
</div>
<input type="number" name="design_multiplier" id="design_multiplier" class="form-control form-control-sm" step="0.05" min="0" value="1.80">
</div>
<div class="mb-4">
<label class="form-label fw-semibold d-flex justify-content-between">
Marge brute <span id="marginDisplay" class="text-muted">27.5 %</span>
</label>
<input type="range" name="gross_margin_pct" id="gross_margin_pct" class="form-range" min="20" max="35" step="0.5" value="27.5">
<div class="d-flex justify-content-between" style="font-size:.75rem;color:#6b7280"><span>20%</span><span>35%</span></div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Remise client (%)</label>
<input type="number" name="discount_pct" id="discount_pct" class="form-control" min="0" max="100" step="0.5" value="0">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Notes internes</label>
<textarea name="notes" class="form-control" rows="2"></textarea>
</div>
</div>
</div>
<button type="submit" class="btn w-100 py-2 fw-bold" style="background:#ff6b35;color:#fff">
<i class="bi bi-save me-2"></i>Enregistrer la commande
</button>
</form>
</div>
<div class="col-lg-5">
<div class="card sticky-top" style="top:1.5rem">
<div class="card-header py-3"><i class="bi bi-receipt me-2"></i>Recapitulatif temps reel</div>
<div class="card-body" id="breakdown">
<div class="text-center text-muted py-4">
<i class="bi bi-calculator fs-2"></i>
<p class="mt-2 small">Remplissez le poids et la duree pour voir le calcul.</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function parse3mf() {
const file = document.getElementById('file3mf').files[0];
if (!file) { alert('Selectionnez un fichier .3mf'); return; }
const status = document.getElementById('parseStatus');
status.textContent = 'Analyse...';
const fd = new FormData();
fd.append('file', file);
fetch('/api/parse-3mf', {method:'POST', body:fd})
.then(r => r.json())
.then(d => {
let filled = [];
if (d.weight_g) { document.getElementById('weight_g').value = d.weight_g; filled.push('poids'); }
if (d.hours != null){ document.getElementById('hours').value = d.hours; filled.push('heures'); }
if (d.minutes != null){ document.getElementById('minutes').value = d.minutes; }
if (d.filename) {
document.getElementById('sourceFile').value = d.filename;
if (!document.querySelector('[name=name]').value)
document.querySelector('[name=name]').value = d.filename;
}
status.textContent = filled.length ? 'Extrait: '+filled.join(', ') : 'Donnees non trouvees';
status.style.color = filled.length ? '#22c55e' : '#f59e0b';
recalc();
})
.catch(() => { status.textContent = 'Erreur de lecture'; status.style.color='#ef4444'; });
}
document.getElementById('materialSelect').addEventListener('change', function() {
const opt = this.options[this.selectedIndex];
const stock = parseFloat(opt.dataset.stock)||0;
const threshold = parseFloat(opt.dataset.threshold)||200;
const info = document.getElementById('stockInfo');
if (!opt.value) { info.textContent=''; return; }
if (stock<=0) info.innerHTML='<span class="text-danger">RUPTURE DE STOCK</span>';
else if (stock<=threshold) info.innerHTML='<span class="text-warning">Stock bas: '+stock.toFixed(0)+' g</span>';
else info.innerHTML='<span class="text-success">Stock: '+stock.toFixed(0)+' g</span>';
recalc();
});
function setDM(val) {
document.getElementById('design_multiplier').value = val;
document.getElementById('dmLabel').textContent = 'x'+val.toFixed(2);
recalc();
}
document.getElementById('gross_margin_pct').addEventListener('input', function(){
document.getElementById('marginDisplay').textContent = this.value+' %'; recalc();
});
document.getElementById('design_multiplier').addEventListener('input', function(){
document.getElementById('dmLabel').textContent = 'x'+parseFloat(this.value).toFixed(2); recalc();
});
document.getElementById('clientSelect').addEventListener('change', function(){
const dm = this.options[this.selectedIndex].dataset.dm;
if (dm) setDM(parseFloat(dm));
});
['weight_g','hours','minutes','discount_pct'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', recalc);
});
function recalc() {
const w = parseFloat(document.getElementById('weight_g').value)||0;
const h = parseInt(document.getElementById('hours').value)||0;
const m = parseInt(document.getElementById('minutes').value)||0;
if (w<=0 || (h===0 && m===0)) return;
const body = {
weight_g: w, hours: h, minutes: m,
design_multiplier: parseFloat(document.getElementById('design_multiplier').value)||1.80,
gross_margin_pct: parseFloat(document.getElementById('gross_margin_pct').value)||27.5,
discount_pct: parseFloat(document.getElementById('discount_pct').value)||0,
material_id: document.getElementById('materialSelect').value||null,
};
fetch('/api/calculate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
.then(r=>r.json()).then(d=>renderBreakdown(d,body));
}
function fmt(n) { return parseFloat(n).toFixed(4)+' EUR'; }
function fmt2(n) { return parseFloat(n).toFixed(2)+' EUR'; }
function renderBreakdown(d, body) {
const dis = body.discount_pct>0
? '<div class="breakdown-row text-danger"><span>Remise ('+body.discount_pct+'%)</span></div>' : '';
document.getElementById('breakdown').innerHTML = `
<div class="breakdown-row"><span class="breakdown-muted">Matiere</span><span>${fmt(d.material_cost)}</span></div>
<div class="breakdown-row"><span class="breakdown-muted">Marge matiere (20%)</span><span>${fmt(d.material_margin)}</span></div>
<div class="breakdown-row"><span class="breakdown-muted">Design (x${body.design_multiplier.toFixed(2)})</span><span>${fmt(d.design_cost)}</span></div>
<div class="breakdown-row"><span class="breakdown-muted">Manutention</span><span>${fmt(d.handling_cost)}</span></div>
<div class="breakdown-row"><span class="breakdown-muted">Usure machine</span><span>${fmt(d.wear_cost)}</span></div>
<div class="breakdown-row"><span class="breakdown-muted">Electricite</span><span>${fmt(d.electricity_cost)}</span></div>
<div class="breakdown-row subtotal"><span class="fw-semibold">Sous-total couts</span><span class="fw-semibold">${fmt2(d.subtotal)}</span></div>
<div class="breakdown-row"><span class="breakdown-muted">Marge brute (${body.gross_margin_pct}%)</span><span>${fmt2(d.margin_amount)}</span></div>
<div class="breakdown-row"><span class="breakdown-muted">Cotisations (12.3%)</span><span>${fmt2(d.tax_amount)}</span></div>
${dis}
<div class="breakdown-row total"><span>PRIX FINAL</span><span class="final-price">${fmt2(d.final_price)}</span></div>`;
}
</script>
{% endblock %}
+132
View File
@@ -0,0 +1,132 @@
{% extends 'base.html' %}
{% block title %}Paramètres{% endblock %}
{% block content %}
<div class="page-header">
<h1><i class="bi bi-sliders me-2 text-secondary"></i>Paramètres</h1>
</div>
<form method="POST">
<div class="row g-4">
<div class="col-md-6">
<div class="card">
<div class="card-header py-3"><i class="bi bi-boxes me-2"></i>Filament</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Prix du filament (€/kg)</label>
<input type="number" name="filament_price_per_kg" class="form-control"
step="0.01" value="{{ settings.filament_price_per_kg }}">
</div>
<div class="mb-3">
<label class="form-label">Marge matière (%)</label>
<input type="number" name="material_margin_pct" class="form-control"
step="0.5" value="{{ settings.material_margin_pct }}">
<div class="form-text">Appliquée sur le coût matière brut.</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header py-3"><i class="bi bi-lightning-charge me-2"></i>Électricité</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Prix kWh (€)</label>
<input type="number" name="electricity_price_kwh" class="form-control"
step="0.001" value="{{ settings.electricity_price_kwh }}">
</div>
<div class="mb-3">
<label class="form-label">Puissance imprimante (kW)</label>
<input type="number" name="printer_power_kw" class="form-control"
step="0.01" value="{{ settings.printer_power_kw }}">
<div class="form-text">Bambu X1C ≈ 0.35 kW en moyenne.</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header py-3"><i class="bi bi-gear me-2"></i>Usure machine</div>
<div class="card-body">
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label">Prix imprimante (€)</label>
<input type="number" name="printer_price" class="form-control"
step="1" value="{{ settings.printer_price }}">
</div>
<div class="col-6">
<label class="form-label">Durée de vie (h)</label>
<input type="number" name="printer_lifespan_hours" class="form-control"
step="100" value="{{ settings.printer_lifespan_hours }}">
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label">Prix buse (€)</label>
<input type="number" name="nozzle_price" class="form-control"
step="0.5" value="{{ settings.nozzle_price }}">
</div>
<div class="col-6">
<label class="form-label">Durée de vie buse (h)</label>
<input type="number" name="nozzle_lifespan_hours" class="form-control"
step="10" value="{{ settings.nozzle_lifespan_hours }}">
</div>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label">Prix plateau (€)</label>
<input type="number" name="plate_price" class="form-control"
step="0.5" value="{{ settings.plate_price }}">
</div>
<div class="col-6">
<label class="form-label">Durée de vie plateau (h)</label>
<input type="number" name="plate_lifespan_hours" class="form-control"
step="50" value="{{ settings.plate_lifespan_hours }}">
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header py-3"><i class="bi bi-hand-index me-2"></i>Manutention</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Taux horaire manutention (€/h)</label>
<input type="number" name="handling_rate_per_hour" class="form-control"
step="1" value="{{ settings.handling_rate_per_hour }}">
</div>
<div class="mb-3">
<label class="form-label">Temps par plateau (minutes)</label>
<input type="number" name="handling_minutes_per_plate" class="form-control"
step="1" value="{{ settings.handling_minutes_per_plate }}">
</div>
</div>
</div>
<div class="card">
<div class="card-header py-3"><i class="bi bi-percent me-2"></i>Fiscal</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Taux cotisations micro-entrepreneur (%)</label>
<input type="number" name="tax_rate_pct" class="form-control"
step="0.1" value="{{ settings.tax_rate_pct }}">
<div class="form-text">12.3% pour vente de marchandises (2024).</div>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn px-4 py-2 fw-bold" style="background:#ff6b35;color:#fff">
<i class="bi bi-save me-2"></i>Enregistrer les paramètres
</button>
</div>
</form>
{% endblock %}
+84
View File
@@ -0,0 +1,84 @@
{% extends 'base.html' %}
{% block title %}Statistiques{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<h1><i class="bi bi-bar-chart-line me-2 text-success"></i>Statistiques</h1>
<a href="{{ url_for('export_csv') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-download me-1"></i>Export CSV
</a>
</div>
<div class="row g-4">
<!-- Par client -->
<div class="col-md-7">
<div class="card">
<div class="card-header py-3">CA par client</div>
<div class="card-body p-0">
{% if by_client %}
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Client</th><th>Commandes</th><th>Marge moy.</th><th class="text-end">CA total</th></tr>
</thead>
<tbody>
{% set total_rev = by_client | sum(attribute='revenue') %}
{% for c in by_client %}
<tr>
<td class="fw-semibold">{{ c.name }}</td>
<td>{{ c.jobs }}</td>
<td><span class="badge bg-light text-dark">{{ c.avg_margin }}%</span></td>
<td class="text-end">
<span class="fw-bold" style="color:#ff6b35">{{ c.revenue }} €</span>
{% if total_rev > 0 %}
<div class="progress mt-1" style="height:4px">
<div class="progress-bar" style="width:{{ (c.revenue / total_rev * 100)|round(0) }}%;background:#ff6b35"></div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center text-muted py-4">Aucune donnée disponible.</div>
{% endif %}
</div>
</div>
</div>
<!-- Par mois -->
<div class="col-md-5">
<div class="card">
<div class="card-header py-3">CA par mois (12 derniers)</div>
<div class="card-body p-0">
{% if by_month %}
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Mois</th><th>Cmds</th><th class="text-end">CA</th></tr>
</thead>
<tbody>
{% set max_rev = by_month | map(attribute='revenue') | max %}
{% for m in by_month %}
<tr>
<td class="fw-semibold">{{ m.month }}</td>
<td>{{ m.jobs }}</td>
<td class="text-end">
<span class="fw-bold">{{ m.revenue }} €</span>
{% if max_rev > 0 %}
<div class="progress mt-1" style="height:4px">
<div class="progress-bar bg-success" style="width:{{ (m.revenue / max_rev * 100)|round(0) }}%"></div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center text-muted py-4">Aucune donnée disponible.</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}