feat: système de projets (Option A) — grouper plusieurs runs sous un projet
Deploy via Portainer / deploy (push) Successful in 0s

This commit is contained in:
Jo
2026-07-06 23:31:17 +02:00
parent 9cb7c71d6f
commit ddd0f6bee5
6 changed files with 373 additions and 6 deletions
+95 -4
View File
@@ -15,6 +15,14 @@ def init_db():
conn = get_db()
conn.executescript('''
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
deadline TEXT DEFAULT NULL,
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, email TEXT DEFAULT '',
@@ -177,6 +185,7 @@ def init_db():
'ALTER TABLE clients ADD COLUMN default_handling_profile_id INTEGER',
'ALTER TABLE clients ADD COLUMN default_material_profile_id INTEGER',
'ALTER TABLE clients ADD COLUMN default_pricing_profile_id INTEGER',
'ALTER TABLE jobs ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL',
]
for sql in migrations:
try:
@@ -592,6 +601,85 @@ def delete_client(id):
conn.commit(); conn.close()
return redirect(url_for('clients'))
# ─── Projets ──────────────────────────────────────────────────────────────────
@app.route('/projects')
def projects():
conn = get_db()
rows = conn.execute('''
SELECT p.*, c.name as client_name,
COUNT(j.id) as job_count,
COALESCE(SUM(j.order_qty), 0) as total_pieces,
COALESCE(SUM(j.final_price * j.order_qty), 0) as total_revenue
FROM projects p
LEFT JOIN clients c ON c.id = p.client_id
LEFT JOIN jobs j ON j.project_id = p.id
GROUP BY p.id ORDER BY p.created_at DESC
''').fetchall()
conn.close()
return render_template('projects.html', projects=rows)
@app.route('/projects/new', methods=['GET','POST'])
def new_project():
conn = get_db()
if request.method == 'POST':
conn.execute('INSERT INTO projects(name,client_id,deadline,notes) VALUES(?,?,?,?)',
(request.form['name'],
int(request.form['client_id']) if request.form.get('client_id') else None,
request.form.get('deadline') or None,
request.form.get('notes','')))
conn.commit(); conn.close()
return redirect(url_for('projects'))
clients = conn.execute('SELECT id,name FROM clients ORDER BY name').fetchall()
conn.close()
return render_template('project_form.html', project=None, clients=clients)
@app.route('/projects/<int:id>', methods=['GET'])
def project_detail(id):
conn = get_db()
project = conn.execute('''
SELECT p.*, c.name as client_name
FROM projects p LEFT JOIN clients c ON c.id=p.client_id
WHERE p.id=?''', (id,)).fetchone()
if not project:
conn.close(); return redirect(url_for('projects'))
jobs = conn.execute('''
SELECT j.*, c.name as client_name, m.name as material_name, m.type as material_type
FROM jobs j
LEFT JOIN clients c ON c.id=j.client_id
LEFT JOIN materials m ON m.id=j.material_id
WHERE j.project_id=?
ORDER BY j.created_at''', (id,)).fetchall()
conn.close()
total_pieces = sum((j['order_qty'] or 1) for j in jobs)
total_revenue = sum((j['final_price'] or 0) * (j['order_qty'] or 1) for j in jobs)
return render_template('project_detail.html', project=project, jobs=jobs,
total_pieces=total_pieces, total_revenue=total_revenue)
@app.route('/projects/<int:id>/edit', methods=['GET','POST'])
def edit_project(id):
conn = get_db()
project = conn.execute('SELECT * FROM projects WHERE id=?',(id,)).fetchone()
if request.method == 'POST':
conn.execute('UPDATE projects SET name=?,client_id=?,deadline=?,notes=? WHERE id=?',
(request.form['name'],
int(request.form['client_id']) if request.form.get('client_id') else None,
request.form.get('deadline') or None,
request.form.get('notes',''), id))
conn.commit(); conn.close()
return redirect(url_for('project_detail', id=id))
clients = conn.execute('SELECT id,name FROM clients ORDER BY name').fetchall()
conn.close()
return render_template('project_form.html', project=project, clients=clients)
@app.route('/projects/<int:id>/delete', methods=['POST'])
def delete_project(id):
conn = get_db()
conn.execute('UPDATE jobs SET project_id=NULL WHERE project_id=?',(id,))
conn.execute('DELETE FROM projects WHERE id=?',(id,))
conn.commit(); conn.close()
return redirect(url_for('projects'))
# ─── Matières ─────────────────────────────────────────────────────────────────
@app.route('/materials')
@@ -885,6 +973,7 @@ def new_job():
handling_pid = request.form.get('handling_profile_id') or None
material_pid = request.form.get('material_profile_id') or None
pricing_pid = request.form.get('pricing_profile_id') or None
project_id = request.form.get('project_id') or None
pieces_per_plate = max(1, int(request.form.get('pieces_per_plate', 1)))
order_qty = max(1, int(request.form.get('order_qty', 1)))
@@ -919,15 +1008,15 @@ def new_job():
r = calc(weight_g, print_time_s, design_mult, gross_margin, discount,
price_kg, s, pieces_per_plate)
conn.execute('''INSERT INTO jobs(client_id,material_id,machine_profile_id,handling_profile_id,
material_profile_id,pricing_profile_id,source_file,name,description,
material_profile_id,pricing_profile_id,project_id,source_file,name,description,
weight_g,print_time_s,design_multiplier,gross_margin_pct,discount_pct,
pieces_per_plate,order_qty,
material_cost,material_margin,design_cost,handling_cost,wear_cost,electricity_cost,
cout_fixe,total_marge,subtotal,margin_amount,
cotisations_amount,vfl_amount,tva_amount,other_taxes_amount,tax_amount,
price_per_piece,final_price,notes)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(client_id,material_id,machine_pid,handling_pid,material_pid,pricing_pid,
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(client_id,material_id,machine_pid,handling_pid,material_pid,pricing_pid,project_id,
source_file,request.form['name'],request.form.get('description',''),
weight_g,print_time_s,design_mult,gross_margin,discount,
pieces_per_plate,order_qty,
@@ -942,10 +1031,12 @@ def new_job():
conn.commit(); conn.close()
return redirect(url_for('jobs'))
projects = conn.execute('SELECT id,name FROM projects ORDER BY name').fetchall()
conn.close()
return render_template('new_job.html', clients=clients, materials=mats, settings=settings,
machine_profiles=machine_profiles, handling_profiles=handling_profiles,
material_profiles=material_profiles, pricing_profiles=pricing_profiles)
material_profiles=material_profiles, pricing_profiles=pricing_profiles,
projects=projects)
@app.route('/jobs/<int:id>')
def job_detail(id):
+3
View File
@@ -87,6 +87,9 @@
<a href="{{ url_for('profiles') }}" class="{% if 'profile' in request.endpoint %}active{% endif %}">
<i class="bi bi-bookmark-star"></i> Templates
</a>
<a href="{{ url_for('projects') }}" class="{% if request.endpoint in ['projects','new_project','edit_project','project_detail'] %}active{% endif %}">
<i class="bi bi-folder2-open"></i> Projets
</a>
<a href="{{ url_for('planning') }}" class="{% if request.endpoint in ['planning','printers_page','working_schedule','calendar_blocks'] %}active{% endif %}">
<i class="bi bi-calendar3"></i> Planning
</a>
+14 -2
View File
@@ -46,7 +46,7 @@
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="col-md-4">
<label class="form-label fw-semibold">Client</label>
<select name="client_id" id="clientSelect" class="form-select">
<option value="">Sans client</option>
@@ -55,7 +55,19 @@
{% endfor %}
</select>
</div>
<div class="col-md-6">
<div class="col-md-4">
<label class="form-label fw-semibold">Projet</label>
<select name="project_id" id="projectSelect" class="form-select">
<option value="">— Sans projet —</option>
{% for p in projects %}
<option value="{{ p.id }}"
{% if request.args.get('project_id')|int == p.id %}selected{% endif %}>
{{ p.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Matiere (filament)</label>
<select name="material_id" id="materialSelect" class="form-select">
<option value="">Prix global (parametres)</option>
+134
View File
@@ -0,0 +1,134 @@
{% extends 'base.html' %}
{% block title %}{{ project.name }}{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<div>
<a href="{{ url_for('projects') }}" class="text-muted text-decoration-none small">
<i class="bi bi-arrow-left"></i> Projets
</a>
<h1 class="mt-1">{{ project.name }}</h1>
<span class="text-muted small">{{ project.client_name or 'Sans client' }}</span>
{% if project.deadline %}
<span class="badge bg-secondary ms-2">Livraison : {{ project.deadline }}</span>
{% endif %}
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('new_job') }}?project_id={{ project.id }}" class="btn btn-outline-secondary">
<i class="bi bi-plus-circle me-1"></i>Ajouter un run
</a>
<a href="{{ url_for('edit_project', id=project.id) }}" class="btn btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Modifier
</a>
</div>
</div>
<!-- Totaux -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="stat-card">
<div class="label">Runs</div>
<div class="value">{{ jobs|length }}</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="label">Pièces totales</div>
<div class="value">{{ total_pieces }}</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="label">CA total commande</div>
<div class="value" style="color:#ff6b35">{{ "%.2f"|format(total_revenue) }} €</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="label">Prix moyen / pièce</div>
<div class="value">
{% if total_pieces > 0 %}{{ "%.2f"|format(total_revenue / total_pieces) }} €{% else %}—{% endif %}
</div>
</div>
</div>
</div>
{% if project.notes %}
<div class="alert alert-light border mb-4"><i class="bi bi-sticky me-2"></i>{{ project.notes }}</div>
{% endif %}
<!-- Liste des runs -->
<div class="card">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<span><i class="bi bi-list-ul me-2"></i>Runs d'impression</span>
</div>
{% if jobs %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Nom du run</th>
<th>Matière</th>
<th class="text-center">Pièces/plateau</th>
<th class="text-center">Qté commandée</th>
<th class="text-center">Plateaux</th>
<th class="text-end">Prix/pièce</th>
<th class="text-end">Total run</th>
<th></th>
</tr>
</thead>
<tbody>
{% set ns = namespace(total_plateaux=0) %}
{% for j in jobs %}
{% set qty = j.order_qty or 1 %}
{% set ppp = j.pieces_per_plate or 1 %}
{% set nb_p = ((qty + ppp - 1) // ppp) %}
{% set ns.total_plateaux = ns.total_plateaux + nb_p %}
<tr>
<td>
<a href="{{ url_for('job_detail', id=j.id) }}" class="fw-semibold text-decoration-none">
{{ j.name }}
</a>
<div class="small text-muted">{{ j.created_at[:10] }}</div>
</td>
<td>
{% if j.material_name %}
<span class="badge bg-secondary">{{ j.material_type }}</span> {{ j.material_name }}
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td class="text-center">{{ ppp }}</td>
<td class="text-center fw-semibold">{{ qty }}</td>
<td class="text-center">{{ nb_p }}</td>
<td class="text-end">{{ "%.2f"|format(j.final_price) }} €</td>
<td class="text-end fw-bold" style="color:#ff6b35">{{ "%.2f"|format(j.final_price * qty) }} €</td>
<td>
<a href="{{ url_for('job_detail', id=j.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="table-light fw-bold">
<tr>
<td colspan="3">Total</td>
<td class="text-center">{{ total_pieces }}</td>
<td class="text-center">{{ ns.total_plateaux }}</td>
<td class="text-end">
{% if total_pieces > 0 %}{{ "%.2f"|format(total_revenue / total_pieces) }} € moy.{% endif %}
</td>
<td class="text-end" style="color:#ff6b35">{{ "%.2f"|format(total_revenue) }} €</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
{% else %}
<div class="card-body text-center text-muted py-4">
<i class="bi bi-printer fs-2 d-block mb-2"></i>
Aucun run encore.
<a href="{{ url_for('new_job') }}">Créer un calcul</a> et l'associer à ce projet.
</div>
{% endif %}
</div>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
{% extends 'base.html' %}
{% block title %}{% if project %}Modifier{% else %}Nouveau{% endif %} projet{% endblock %}
{% block content %}
<div class="page-header">
<a href="{{ url_for('projects') }}" class="text-muted text-decoration-none small">
<i class="bi bi-arrow-left"></i> Projets
</a>
<h1 class="mt-1">{% if project %}Modifier {{ project.name }}{% else %}Nouveau projet{% 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 du projet *</label>
<input type="text" name="name" class="form-control" required
value="{{ project.name if project else '' }}"
placeholder="ex: Commande BlokGrip Q3 2026">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Client</label>
<select name="client_id" class="form-select">
<option value="">— Sans client —</option>
{% for c in clients %}
<option value="{{ c.id }}"
{% if project and project.client_id == c.id %}selected{% endif %}>
{{ c.name }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Date limite de livraison</label>
<input type="date" name="deadline" class="form-control"
value="{{ project.deadline if project and project.deadline else '' }}">
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Notes</label>
<textarea name="notes" class="form-control" rows="3"
placeholder="Description, contraintes, références...">{{ project.notes if project 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('projects') }}" class="btn btn-outline-secondary">Annuler</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends 'base.html' %}
{% block title %}Projets{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<div>
<h1><i class="bi bi-folder2-open me-2 text-secondary"></i>Projets</h1>
<p class="text-muted small mt-1">Regroupez plusieurs impressions (runs) sous un même projet client.</p>
</div>
<a href="{{ url_for('new_project') }}" class="btn" style="background:#ff6b35;color:#fff">
<i class="bi bi-plus-lg me-1"></i>Nouveau projet
</a>
</div>
{% if projects %}
<div class="row g-3">
{% for p in projects %}
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="mb-0">
<a href="{{ url_for('project_detail', id=p.id) }}" class="text-decoration-none text-dark">
{{ p.name }}
</a>
</h5>
<small class="text-muted">{{ p.client_name or 'Sans client' }}</small>
</div>
{% if p.deadline %}
<span class="badge bg-secondary">{{ p.deadline }}</span>
{% endif %}
</div>
<div class="d-flex gap-4 mt-3">
<div>
<div class="small text-muted">Runs</div>
<div class="fw-bold">{{ p.job_count }}</div>
</div>
<div>
<div class="small text-muted">Pièces totales</div>
<div class="fw-bold">{{ p.total_pieces }}</div>
</div>
<div>
<div class="small text-muted">CA total</div>
<div class="fw-bold" style="color:#ff6b35">{{ "%.2f"|format(p.total_revenue) }} €</div>
</div>
</div>
</div>
<div class="card-footer bg-transparent d-flex gap-2 py-2">
<a href="{{ url_for('project_detail', id=p.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i> Voir
</a>
<a href="{{ url_for('edit_project', id=p.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i> Modifier
</a>
<form method="POST" action="{{ url_for('delete_project', id=p.id) }}"
onsubmit="return confirm('Supprimer ce projet ? Les jobs seront conservés mais détachés.')">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-folder2 fs-1 d-block mb-3"></i>
Aucun projet. <a href="{{ url_for('new_project') }}">Créer le premier</a>.
</div>
{% endif %}
{% endblock %}