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
+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 %}