feat: viewer 3D + parser 3MF par plaque + échecs filament + portail client
Deploy via Portainer / deploy (push) Successful in 0s
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
@@ -1,10 +1,75 @@
|
|||||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, Response
|
from flask import Flask, render_template, request, redirect, url_for, jsonify, Response, session, flash
|
||||||
import sqlite3, csv, io, os, zipfile, re, math
|
import sqlite3, csv, io, os, zipfile, re, math, secrets, functools
|
||||||
from datetime import datetime, timedelta, date as date_cls
|
from datetime import datetime, timedelta, date as date_cls
|
||||||
|
from authlib.integrations.flask_client import OAuth
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32))
|
||||||
DATABASE = os.environ.get('DATABASE_PATH', '/data/pricing.db')
|
DATABASE = os.environ.get('DATABASE_PATH', '/data/pricing.db')
|
||||||
|
|
||||||
|
# ─── OIDC ─────────────────────────────────────────────────────────────────────
|
||||||
|
OIDC_ENABLED = bool(os.environ.get('OIDC_CLIENT_ID'))
|
||||||
|
|
||||||
|
oauth = OAuth(app)
|
||||||
|
if OIDC_ENABLED:
|
||||||
|
oauth.register(
|
||||||
|
name='authelia',
|
||||||
|
client_id=os.environ.get('OIDC_CLIENT_ID'),
|
||||||
|
client_secret=os.environ.get('OIDC_CLIENT_SECRET'),
|
||||||
|
server_metadata_url=os.environ.get('OIDC_DISCOVERY_URL'),
|
||||||
|
client_kwargs={
|
||||||
|
'scope': 'openid profile email',
|
||||||
|
'code_challenge_method': 'S256', # PKCE
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
@functools.wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if OIDC_ENABLED and 'user' not in session:
|
||||||
|
session['next'] = request.url
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
@app.route('/login')
|
||||||
|
def login():
|
||||||
|
if not OIDC_ENABLED:
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
redirect_uri = url_for('auth_callback', _external=True)
|
||||||
|
return oauth.authelia.authorize_redirect(redirect_uri)
|
||||||
|
|
||||||
|
@app.route('/auth/callback')
|
||||||
|
def auth_callback():
|
||||||
|
token = oauth.authelia.authorize_access_token()
|
||||||
|
userinfo = token.get('userinfo') or oauth.authelia.userinfo()
|
||||||
|
session['user'] = {
|
||||||
|
'sub': userinfo.get('sub', ''),
|
||||||
|
'name': userinfo.get('name') or userinfo.get('preferred_username', 'Utilisateur'),
|
||||||
|
'email': userinfo.get('email', ''),
|
||||||
|
}
|
||||||
|
next_url = session.pop('next', None)
|
||||||
|
return redirect(next_url or url_for('dashboard'))
|
||||||
|
|
||||||
|
@app.route('/logout')
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
if OIDC_ENABLED:
|
||||||
|
end_session = os.environ.get('OIDC_END_SESSION_URL', '')
|
||||||
|
if end_session:
|
||||||
|
post_logout = url_for('login', _external=True)
|
||||||
|
return redirect(f"{end_session}?post_logout_redirect_uri={post_logout}")
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
# Routes accessibles sans login
|
||||||
|
_OPEN_ROUTES = {'login', 'auth_callback', 'logout', 'static', 'client_portal', 'calendar_ics'}
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def require_login_global():
|
||||||
|
if OIDC_ENABLED and request.endpoint not in _OPEN_ROUTES and 'user' not in session:
|
||||||
|
session['next'] = request.url
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
conn = sqlite3.connect(DATABASE)
|
conn = sqlite3.connect(DATABASE)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
@@ -150,6 +215,10 @@ def init_db():
|
|||||||
('cooldown_minutes','15'),
|
('cooldown_minutes','15'),
|
||||||
('failure_buffer_pct','10'),
|
('failure_buffer_pct','10'),
|
||||||
('max_simultaneous_printers','5'),
|
('max_simultaneous_printers','5'),
|
||||||
|
('nc_url',''),
|
||||||
|
('nc_username',''),
|
||||||
|
('nc_app_password',''),
|
||||||
|
('nc_root_path','/3D'),
|
||||||
]
|
]
|
||||||
conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults)
|
conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults)
|
||||||
|
|
||||||
@@ -186,6 +255,9 @@ def init_db():
|
|||||||
'ALTER TABLE clients ADD COLUMN default_material_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 clients ADD COLUMN default_pricing_profile_id INTEGER',
|
||||||
'ALTER TABLE jobs ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL',
|
'ALTER TABLE jobs ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL',
|
||||||
|
'ALTER TABLE print_slots ADD COLUMN wasted_g REAL DEFAULT 0',
|
||||||
|
'ALTER TABLE materials ADD COLUMN total_wasted_g REAL DEFAULT 0',
|
||||||
|
'ALTER TABLE jobs ADD COLUMN client_token TEXT DEFAULT NULL',
|
||||||
]
|
]
|
||||||
for sql in migrations:
|
for sql in migrations:
|
||||||
try:
|
try:
|
||||||
@@ -199,49 +271,100 @@ def get_settings():
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
rows = conn.execute('SELECT key,value FROM settings').fetchall()
|
rows = conn.execute('SELECT key,value FROM settings').fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {r['key']: float(r['value']) for r in rows}
|
result = {}
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
result[r['key']] = float(r['value'])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
result[r['key']] = r['value'] or ''
|
||||||
|
return result
|
||||||
|
|
||||||
def parse_3mf(file_stream):
|
def parse_3mf(file_stream):
|
||||||
result = {'weight_g': None, 'hours': None, 'minutes': None, 'filename': None}
|
"""
|
||||||
|
Parse un fichier .3mf Bambu Studio.
|
||||||
|
Retourne un dict avec :
|
||||||
|
- plates : liste de plaques [{index, weight_g, print_time_s, filaments:[{type,color,used_g}]}]
|
||||||
|
- fallback : {weight_g, hours, minutes} si slice_info absent
|
||||||
|
"""
|
||||||
|
result = {'plates': [], 'weight_g': None, 'hours': None, 'minutes': None}
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(file_stream) as z:
|
with zipfile.ZipFile(file_stream) as z:
|
||||||
for name in z.namelist():
|
names = z.namelist()
|
||||||
if not any(name.endswith(e) for e in ['.xml','.model','.config','.json','.txt']):
|
|
||||||
continue
|
# ── 1. Bambu slice_info.config (format XML par plaque) ──────────
|
||||||
|
slice_info_candidates = [n for n in names if 'slice_info' in n.lower() and n.endswith('.config')]
|
||||||
|
if slice_info_candidates:
|
||||||
|
raw = z.read(slice_info_candidates[0]).decode('utf-8', errors='ignore')
|
||||||
try:
|
try:
|
||||||
raw = z.read(name).decode('utf-8', errors='ignore')
|
root = ET.fromstring(raw)
|
||||||
|
for plate_el in root.findall('plate'):
|
||||||
|
plate = {}
|
||||||
|
meta = {m.get('key'): m.get('value') for m in plate_el.findall('metadata')}
|
||||||
|
plate['index'] = int(meta.get('index', 0))
|
||||||
|
plate['print_time_s'] = int(meta.get('prediction', 0))
|
||||||
|
|
||||||
|
filaments = []
|
||||||
|
total_g = 0.0
|
||||||
|
for fil in plate_el.findall('filament'):
|
||||||
|
used_g = float(fil.get('used_g', 0) or 0)
|
||||||
|
total_g += used_g
|
||||||
|
filaments.append({
|
||||||
|
'id': fil.get('id', ''),
|
||||||
|
'type': fil.get('type', ''),
|
||||||
|
'color': fil.get('color', ''),
|
||||||
|
'used_g': round(used_g, 2),
|
||||||
|
'used_m': round(float(fil.get('used_m', 0) or 0), 2),
|
||||||
|
})
|
||||||
|
plate['weight_g'] = round(total_g, 2)
|
||||||
|
plate['filaments'] = filaments
|
||||||
|
# Libellé lisible
|
||||||
|
h = plate['print_time_s'] // 3600
|
||||||
|
mn = (plate['print_time_s'] % 3600) // 60
|
||||||
|
plate['label'] = f"Plateau {plate['index']} — {plate['weight_g']} g — {h}h{mn:02d}m"
|
||||||
|
result['plates'].append(plate)
|
||||||
|
result['plates'].sort(key=lambda p: p['index'])
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
pass
|
||||||
if result['weight_g'] is None:
|
|
||||||
for pat in [
|
# ── 2. Fallback regex si slice_info absent ──────────────────────
|
||||||
r'filament[_\s]*weight[^>]*?["\s](\d+[\.,]\d+)',
|
if not result['plates']:
|
||||||
r'(\d+[\.,]\d+)\s*g\b(?!\s*[/])',
|
for name in names:
|
||||||
r'"weight"\s*[:=]\s*"?(\d+[\.,]\d+)',
|
if not any(name.endswith(e) for e in ['.xml', '.model', '.config', '.json', '.txt']):
|
||||||
r'used\s*\[g\][^"]*"\s*value\s*=\s*"(\d+[\.,]\d+)',
|
continue
|
||||||
]:
|
try:
|
||||||
m = re.search(pat, raw, re.IGNORECASE)
|
raw = z.read(name).decode('utf-8', errors='ignore')
|
||||||
if m:
|
except Exception:
|
||||||
try:
|
continue
|
||||||
result['weight_g'] = float(m.group(1).replace(',','.'))
|
if result['weight_g'] is None:
|
||||||
break
|
for pat in [
|
||||||
except ValueError:
|
r'filament[_\s]*weight[^>]*?["\s](\d+[\.,]\d+)',
|
||||||
pass
|
r'used\s*\[g\][^"]*"\s*value\s*=\s*"(\d+[\.,]\d+)',
|
||||||
if result['hours'] is None:
|
r'"weight"\s*[:=]\s*"?(\d+[\.,]\d+)',
|
||||||
for pat in [
|
r'(\d+[\.,]\d+)\s*g\b(?!\s*[/])',
|
||||||
r'(\d+)h\s*(\d+)m',
|
]:
|
||||||
r'(\d+)\s*hours?\s*(\d+)\s*min',
|
m = re.search(pat, raw, re.IGNORECASE)
|
||||||
r'printing.time[^"]*"\s*value\s*=\s*"(\d+)h\s*(\d+)',
|
if m:
|
||||||
]:
|
try:
|
||||||
m = re.search(pat, raw, re.IGNORECASE)
|
result['weight_g'] = float(m.group(1).replace(',', '.'))
|
||||||
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
|
break
|
||||||
except (ValueError, IndexError):
|
except ValueError:
|
||||||
pass
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return result
|
return result
|
||||||
@@ -1209,6 +1332,117 @@ def delete_calendar_block(id):
|
|||||||
conn.commit(); conn.close()
|
conn.commit(); conn.close()
|
||||||
return redirect(url_for('calendar_blocks'))
|
return redirect(url_for('calendar_blocks'))
|
||||||
|
|
||||||
|
# ─── Page mobile saisie rapide ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route('/mobile', methods=['GET', 'POST'])
|
||||||
|
def mobile_quick():
|
||||||
|
conn = get_db()
|
||||||
|
if request.method == 'POST':
|
||||||
|
action = request.form.get('action')
|
||||||
|
if action == 'block':
|
||||||
|
conn.execute(
|
||||||
|
'INSERT INTO calendar_blocks(date,type,printer_id,notes) VALUES(?,?,?,?)',
|
||||||
|
(request.form['date'], request.form.get('type', 'off'),
|
||||||
|
request.form.get('printer_id') or None,
|
||||||
|
request.form.get('notes', '')))
|
||||||
|
conn.commit()
|
||||||
|
elif action == 'shift':
|
||||||
|
slot_id = request.form.get('slot_id')
|
||||||
|
new_start = request.form.get('new_start')
|
||||||
|
if slot_id and new_start:
|
||||||
|
slot = conn.execute('SELECT * FROM print_slots WHERE id=?', (slot_id,)).fetchone()
|
||||||
|
if slot:
|
||||||
|
try:
|
||||||
|
orig_s = datetime.fromisoformat(slot['planned_start'])
|
||||||
|
orig_e = datetime.fromisoformat(slot['planned_end'])
|
||||||
|
new_s = datetime.fromisoformat(new_start)
|
||||||
|
delta = new_s - orig_s
|
||||||
|
new_e = (orig_e + delta).isoformat()
|
||||||
|
conn.execute('UPDATE print_slots SET planned_start=?,planned_end=? WHERE id=?',
|
||||||
|
(new_s.isoformat(), new_e, slot_id))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif action == 'fail':
|
||||||
|
slot_id = request.form.get('slot_id')
|
||||||
|
note = request.form.get('failure_note', '')
|
||||||
|
if slot_id:
|
||||||
|
conn.execute("UPDATE print_slots SET status='failed',failure_note=? WHERE id=?",
|
||||||
|
(note, slot_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return redirect(url_for('mobile_quick'))
|
||||||
|
|
||||||
|
printers = conn.execute('SELECT * FROM printers WHERE is_active=1 ORDER BY id').fetchall()
|
||||||
|
today = date_cls.today().isoformat()
|
||||||
|
upcoming = conn.execute('''
|
||||||
|
SELECT ps.*, j.name as job_name, p.name as printer_name,
|
||||||
|
c.name as client_name
|
||||||
|
FROM print_slots ps
|
||||||
|
JOIN jobs j ON ps.job_id=j.id
|
||||||
|
JOIN printers p ON ps.printer_id=p.id
|
||||||
|
LEFT JOIN clients c ON j.client_id=c.id
|
||||||
|
WHERE ps.status='planned' AND date(ps.planned_start) >= ?
|
||||||
|
ORDER BY ps.planned_start LIMIT 20''', (today,)).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return render_template('mobile.html', printers=printers,
|
||||||
|
upcoming=upcoming, today=today)
|
||||||
|
|
||||||
|
# ─── Gestion échecs impression ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route('/api/slots/<int:id>/fail', methods=['POST'])
|
||||||
|
def fail_slot(id):
|
||||||
|
"""Marque un créneau comme échoué, log le gaspillage filament."""
|
||||||
|
conn = get_db()
|
||||||
|
slot = conn.execute('''
|
||||||
|
SELECT ps.*, j.weight_g, j.material_id, j.pieces_per_plate
|
||||||
|
FROM print_slots ps JOIN jobs j ON ps.job_id=j.id
|
||||||
|
WHERE ps.id=?''', (id,)).fetchone()
|
||||||
|
if not slot:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': 'Créneau introuvable'}), 404
|
||||||
|
|
||||||
|
note = request.json.get('note', '') if request.is_json else request.form.get('note', '')
|
||||||
|
wasted_g = float(request.json.get('wasted_g', 0) if request.is_json else request.form.get('wasted_g', 0) or 0)
|
||||||
|
|
||||||
|
# Si wasted_g non précisé, estimer = poids plateau complet
|
||||||
|
if wasted_g == 0 and slot['weight_g']:
|
||||||
|
wasted_g = slot['weight_g']
|
||||||
|
|
||||||
|
conn.execute("""UPDATE print_slots
|
||||||
|
SET status='failed', failure_note=?, wasted_g=?
|
||||||
|
WHERE id=?""", (note, wasted_g, id))
|
||||||
|
|
||||||
|
# Déduire du stock matière si lié
|
||||||
|
if slot['material_id'] and wasted_g > 0:
|
||||||
|
conn.execute('UPDATE materials SET stock_g = MAX(0, stock_g - ?), total_wasted_g = COALESCE(total_wasted_g,0) + ? WHERE id=?',
|
||||||
|
(wasted_g, wasted_g, slot['material_id']))
|
||||||
|
|
||||||
|
conn.commit(); conn.close()
|
||||||
|
return jsonify({'ok': True, 'wasted_g': wasted_g})
|
||||||
|
|
||||||
|
@app.route('/api/slots/<int:id>/done', methods=['POST'])
|
||||||
|
def done_slot(id):
|
||||||
|
"""Marque un créneau comme terminé avec le poids réel consommé."""
|
||||||
|
conn = get_db()
|
||||||
|
slot = conn.execute('''
|
||||||
|
SELECT ps.*, j.material_id FROM print_slots ps
|
||||||
|
JOIN jobs j ON ps.job_id=j.id WHERE ps.id=?''', (id,)).fetchone()
|
||||||
|
if not slot:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': 'Créneau introuvable'}), 404
|
||||||
|
|
||||||
|
actual_g = float(request.json.get('actual_g', 0) if request.is_json else request.form.get('actual_g', 0) or 0)
|
||||||
|
conn.execute("UPDATE print_slots SET status='done' WHERE id=?", (id,))
|
||||||
|
|
||||||
|
# Déduire le poids réel consommé du stock
|
||||||
|
if slot['material_id'] and actual_g > 0:
|
||||||
|
conn.execute('UPDATE materials SET stock_g = MAX(0, stock_g - ?) WHERE id=?',
|
||||||
|
(actual_g, slot['material_id']))
|
||||||
|
|
||||||
|
conn.commit(); conn.close()
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
# ─── API ──────────────────────────────────────────────────────────────────────
|
# ─── API ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/api/calculate', methods=['POST'])
|
@app.route('/api/calculate', methods=['POST'])
|
||||||
@@ -1297,6 +1531,12 @@ def api_parse_3mf():
|
|||||||
return jsonify({'error': 'Format .3mf requis'}), 400
|
return jsonify({'error': 'Format .3mf requis'}), 400
|
||||||
result = parse_3mf(f.stream)
|
result = parse_3mf(f.stream)
|
||||||
result['filename'] = os.path.splitext(f.filename)[0]
|
result['filename'] = os.path.splitext(f.filename)[0]
|
||||||
|
# Si une seule plaque, pré-sélectionner automatiquement
|
||||||
|
if result['plates'] and len(result['plates']) == 1:
|
||||||
|
p = result['plates'][0]
|
||||||
|
result['weight_g'] = p['weight_g']
|
||||||
|
result['hours'] = p['print_time_s'] // 3600
|
||||||
|
result['minutes'] = (p['print_time_s'] % 3600) // 60
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
@app.route('/api/slots')
|
@app.route('/api/slots')
|
||||||
@@ -1417,12 +1657,25 @@ def api_availability():
|
|||||||
|
|
||||||
# ─── iCal Export ──────────────────────────────────────────────────────────────
|
# ─── iCal Export ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ical_escape(s):
|
||||||
|
return str(s).replace('\\','\\\\').replace('\n','\\n').replace(',','\\,').replace(';','\\;')
|
||||||
|
|
||||||
|
def _ical_dt(dt_str):
|
||||||
|
return dt_str.replace('-','').replace(':','').replace(' ','T').split('.')[0]
|
||||||
|
|
||||||
@app.route('/calendar.ics')
|
@app.route('/calendar.ics')
|
||||||
def calendar_ics():
|
def calendar_ics():
|
||||||
|
# Token optionnel : si ical_token est défini dans les settings, vérifier ?token=xxx
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
s_row = conn.execute("SELECT value FROM settings WHERE key='ical_token'").fetchone()
|
||||||
|
ical_token = s_row['value'] if s_row else ''
|
||||||
|
if ical_token and request.args.get('token', '') != ical_token:
|
||||||
|
conn.close()
|
||||||
|
return Response('Non autorisé', status=401)
|
||||||
|
|
||||||
slots = conn.execute('''
|
slots = conn.execute('''
|
||||||
SELECT ps.*, j.name as job_name, j.print_time_s,
|
SELECT ps.*, j.name as job_name, j.order_qty, j.pieces_per_plate,
|
||||||
j.order_qty, j.pieces_per_plate, j.weight_g,
|
j.weight_g, j.notes as job_notes,
|
||||||
p.name as printer_name, c.name as client_name
|
p.name as printer_name, c.name as client_name
|
||||||
FROM print_slots ps
|
FROM print_slots ps
|
||||||
JOIN jobs j ON ps.job_id=j.id
|
JOIN jobs j ON ps.job_id=j.id
|
||||||
@@ -1430,8 +1683,15 @@ def calendar_ics():
|
|||||||
LEFT JOIN clients c ON j.client_id=c.id
|
LEFT JOIN clients c ON j.client_id=c.id
|
||||||
WHERE ps.status NOT IN ('cancelled')
|
WHERE ps.status NOT IN ('cancelled')
|
||||||
ORDER BY ps.planned_start''').fetchall()
|
ORDER BY ps.planned_start''').fetchall()
|
||||||
|
|
||||||
|
blocks = conn.execute('''
|
||||||
|
SELECT cb.*, p.name as printer_name
|
||||||
|
FROM calendar_blocks cb
|
||||||
|
LEFT JOIN printers p ON cb.printer_id=p.id
|
||||||
|
ORDER BY cb.date''').fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
now_str = datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
|
||||||
lines = [
|
lines = [
|
||||||
'BEGIN:VCALENDAR',
|
'BEGIN:VCALENDAR',
|
||||||
'VERSION:2.0',
|
'VERSION:2.0',
|
||||||
@@ -1440,33 +1700,249 @@ def calendar_ics():
|
|||||||
'METHOD:PUBLISH',
|
'METHOD:PUBLISH',
|
||||||
'X-WR-CALNAME:Planning Impression 3D',
|
'X-WR-CALNAME:Planning Impression 3D',
|
||||||
'X-WR-TIMEZONE:Europe/Paris',
|
'X-WR-TIMEZONE:Europe/Paris',
|
||||||
|
'REFRESH-INTERVAL;VALUE=DURATION:PT1H',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Créneaux d'impression
|
||||||
for s in slots:
|
for s in slots:
|
||||||
start = s['planned_start'].replace('-','').replace(':','').replace(' ','T')
|
start = _ical_dt(s['planned_start'])
|
||||||
if '.' in start:
|
end = _ical_dt(s['planned_end'])
|
||||||
start = start.split('.')[0]
|
|
||||||
end = s['planned_end'].replace('-','').replace(':','').replace(' ','T')
|
|
||||||
if '.' in end:
|
|
||||||
end = end.split('.')[0]
|
|
||||||
if 'T' not in start:
|
|
||||||
start += 'T000000'
|
|
||||||
if 'T' not in end:
|
|
||||||
end += 'T000000'
|
|
||||||
client_part = f" — {s['client_name']}" if s['client_name'] else ''
|
client_part = f" — {s['client_name']}" if s['client_name'] else ''
|
||||||
|
desc_parts = [
|
||||||
|
f"Qty: {s['order_qty']} pcs | {s['pieces_per_plate']} pcs/plateau",
|
||||||
|
f"Statut: {s['status']}",
|
||||||
|
]
|
||||||
|
if s['failure_note']:
|
||||||
|
desc_parts.append(f"Note: {s['failure_note']}")
|
||||||
|
if s['job_notes']:
|
||||||
|
desc_parts.append(s['job_notes'])
|
||||||
lines += [
|
lines += [
|
||||||
'BEGIN:VEVENT',
|
'BEGIN:VEVENT',
|
||||||
f"DTSTART:{start}",
|
f"DTSTART:{start}",
|
||||||
f"DTEND:{end}",
|
f"DTEND:{end}",
|
||||||
f"SUMMARY:{s['job_name']}{client_part} [{s['printer_name']}]",
|
f"DTSTAMP:{now_str}",
|
||||||
f"DESCRIPTION:Qty: {s['order_qty']} pcs | {s['pieces_per_plate']} pcs/plateau | Statut: {s['status']}",
|
f"SUMMARY:{_ical_escape(s['job_name'] + client_part + ' [' + s['printer_name'] + ']')}",
|
||||||
|
f"DESCRIPTION:{_ical_escape(' | '.join(desc_parts))}",
|
||||||
f"UID:slot-{s['id']}@3d-pricing",
|
f"UID:slot-{s['id']}@3d-pricing",
|
||||||
f"STATUS:{'CONFIRMED' if s['status']=='done' else 'TENTATIVE'}",
|
f"STATUS:{'CONFIRMED' if s['status']=='done' else 'TENTATIVE'}",
|
||||||
|
f"CATEGORIES:{'Terminé' if s['status']=='done' else 'Planifié'}",
|
||||||
'END:VEVENT',
|
'END:VEVENT',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Jours indisponibles / bloqués
|
||||||
|
for b in blocks:
|
||||||
|
date_val = b['date'].replace('-', '')
|
||||||
|
# Date suivante pour DTEND all-day
|
||||||
|
d_obj = datetime.strptime(b['date'], '%Y-%m-%d') + timedelta(days=1)
|
||||||
|
end_val = d_obj.strftime('%Y%m%d')
|
||||||
|
label_map = {'off': '🔴 Jour off', 'maintenance': '🔧 Maintenance', 'other': '📌 Bloqué'}
|
||||||
|
label = label_map.get(b['type'], b['type'])
|
||||||
|
if b['printer_name']:
|
||||||
|
label += f" — {b['printer_name']}"
|
||||||
|
notes = b['notes'] or ''
|
||||||
|
lines += [
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
f"DTSTART;VALUE=DATE:{date_val}",
|
||||||
|
f"DTEND;VALUE=DATE:{end_val}",
|
||||||
|
f"DTSTAMP:{now_str}",
|
||||||
|
f"SUMMARY:{_ical_escape(label)}",
|
||||||
|
f"DESCRIPTION:{_ical_escape(notes)}",
|
||||||
|
f"UID:block-{b['id']}@3d-pricing",
|
||||||
|
'TRANSP:TRANSPARENT',
|
||||||
|
'STATUS:CONFIRMED',
|
||||||
|
'END:VEVENT',
|
||||||
|
]
|
||||||
|
|
||||||
lines.append('END:VCALENDAR')
|
lines.append('END:VCALENDAR')
|
||||||
ical = '\r\n'.join(lines)
|
ical = '\r\n'.join(lines)
|
||||||
return Response(ical, mimetype='text/calendar',
|
return Response(ical, mimetype='text/calendar; charset=utf-8',
|
||||||
headers={'Content-Disposition': 'attachment; filename=planning_3d.ics'})
|
headers={'Content-Disposition': 'inline; filename=planning_3d.ics'})
|
||||||
|
|
||||||
|
# ─── Nextcloud WebDAV ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import requests as _requests
|
||||||
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
|
def _nc_session():
|
||||||
|
"""Retourne (session_requests, base_url, username) ou None si non configuré."""
|
||||||
|
s = get_settings()
|
||||||
|
url = str(s.get('nc_url', '')).rstrip('/')
|
||||||
|
user = str(s.get('nc_username', ''))
|
||||||
|
pwd = str(s.get('nc_app_password', ''))
|
||||||
|
if not url or not user or not pwd:
|
||||||
|
return None, None, None
|
||||||
|
sess = _requests.Session()
|
||||||
|
sess.auth = (user, pwd)
|
||||||
|
sess.headers['OCS-APIRequest'] = 'true'
|
||||||
|
return sess, url, user
|
||||||
|
|
||||||
|
def _dav_url(base_url, username, path):
|
||||||
|
"""Construit l'URL WebDAV pour un chemin Nextcloud."""
|
||||||
|
clean = path.lstrip('/')
|
||||||
|
return f"{base_url}/remote.php/dav/files/{quote(username)}/{quote(clean, safe='/')}"
|
||||||
|
|
||||||
|
def _parse_propfind(xml_bytes, base_url, username, current_path):
|
||||||
|
"""Parse la réponse PROPFIND et retourne liste de dicts {name, path, is_dir, size, mtime}."""
|
||||||
|
ns = {'d': 'DAV:'}
|
||||||
|
root = ET.fromstring(xml_bytes)
|
||||||
|
items = []
|
||||||
|
dav_prefix = f"/remote.php/dav/files/{username}/"
|
||||||
|
|
||||||
|
for resp in root.findall('d:response', ns):
|
||||||
|
href = resp.findtext('d:href', '', ns)
|
||||||
|
# Enlever le préfixe DAV pour obtenir le chemin relatif
|
||||||
|
rel = unquote(href).replace(f"/remote.php/dav/files/{username}", '').rstrip('/')
|
||||||
|
if not rel:
|
||||||
|
rel = '/'
|
||||||
|
# Ignorer l'entrée courante elle-même
|
||||||
|
norm_current = ('/' + current_path.strip('/')).rstrip('/')
|
||||||
|
if rel == norm_current or (rel + '/') == norm_current:
|
||||||
|
continue
|
||||||
|
|
||||||
|
propstat = resp.find('d:propstat/d:prop', ns)
|
||||||
|
is_dir = propstat.find('d:resourcetype/d:collection', ns) is not None if propstat is not None else False
|
||||||
|
size = int(propstat.findtext('d:getcontentlength', '0', ns) or 0) if propstat is not None else 0
|
||||||
|
mtime = propstat.findtext('d:getlastmodified', '', ns) if propstat is not None else ''
|
||||||
|
name = rel.split('/')[-1] or rel
|
||||||
|
|
||||||
|
# Filtrer : dossiers ou extensions 3D
|
||||||
|
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
|
||||||
|
if not is_dir and ext not in ('3mf', 'stl', 'step', 'stp', 'obj', 'gcode'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
items.append({'name': name, 'path': rel, 'is_dir': is_dir,
|
||||||
|
'size': size, 'mtime': mtime, 'ext': ext if not is_dir else 'dir'})
|
||||||
|
|
||||||
|
items.sort(key=lambda x: (0 if x['is_dir'] else 1, x['name'].lower()))
|
||||||
|
return items
|
||||||
|
|
||||||
|
@app.route('/api/nextcloud/browse')
|
||||||
|
def nc_browse():
|
||||||
|
path = request.args.get('path', '')
|
||||||
|
sess, base_url, username = _nc_session()
|
||||||
|
if not sess:
|
||||||
|
return jsonify({'error': 'Nextcloud non configuré'}), 503
|
||||||
|
|
||||||
|
# Si path vide, partir du nc_root_path
|
||||||
|
if not path:
|
||||||
|
s = get_settings()
|
||||||
|
path = str(s.get('nc_root_path', '/3D'))
|
||||||
|
|
||||||
|
dav_url = _dav_url(base_url, username, path)
|
||||||
|
try:
|
||||||
|
r = sess.request('PROPFIND', dav_url,
|
||||||
|
headers={'Depth': '1', 'Content-Type': 'application/xml'},
|
||||||
|
data='<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop>'
|
||||||
|
'<d:resourcetype/><d:getcontentlength/><d:getlastmodified/>'
|
||||||
|
'</d:prop></d:propfind>',
|
||||||
|
timeout=10)
|
||||||
|
if r.status_code == 404:
|
||||||
|
return jsonify({'error': f'Chemin introuvable : {path}'}), 404
|
||||||
|
if r.status_code not in (207, 200):
|
||||||
|
return jsonify({'error': f'Erreur WebDAV {r.status_code}'}), 502
|
||||||
|
items = _parse_propfind(r.content, base_url, username, path)
|
||||||
|
# Construire le breadcrumb
|
||||||
|
parts = [p for p in path.split('/') if p]
|
||||||
|
breadcrumb = [{'name': 'Nextcloud', 'path': '/'}]
|
||||||
|
for i, p in enumerate(parts):
|
||||||
|
breadcrumb.append({'name': p, 'path': '/' + '/'.join(parts[:i+1])})
|
||||||
|
return jsonify({'path': path, 'items': items, 'breadcrumb': breadcrumb})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/nextcloud/link')
|
||||||
|
def nc_link():
|
||||||
|
"""Génère un lien de partage Nextcloud pour un fichier via l'API OCS."""
|
||||||
|
path = request.args.get('path', '')
|
||||||
|
sess, base_url, username = _nc_session()
|
||||||
|
if not sess or not path:
|
||||||
|
return jsonify({'error': 'Paramètre manquant ou Nextcloud non configuré'}), 400
|
||||||
|
try:
|
||||||
|
r = sess.post(
|
||||||
|
f"{base_url}/ocs/v2.php/apps/files_sharing/api/v1/shares",
|
||||||
|
data={'path': path, 'shareType': 3, 'permissions': 1}, # 3=public link, 1=read
|
||||||
|
headers={'Accept': 'application/json'},
|
||||||
|
timeout=10)
|
||||||
|
data = r.json()
|
||||||
|
token = data.get('ocs', {}).get('data', {}).get('token', '')
|
||||||
|
if token:
|
||||||
|
return jsonify({'url': f"{base_url}/s/{token}"})
|
||||||
|
return jsonify({'error': 'Impossible de créer le lien'}), 500
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
# ─── Portail client ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route('/jobs/<int:id>/generate-token', methods=['POST'])
|
||||||
|
def generate_client_token(id):
|
||||||
|
"""Génère ou regénère le token de partage client pour une commande."""
|
||||||
|
token = secrets.token_urlsafe(20)
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute('UPDATE jobs SET client_token=? WHERE id=?', (token, id))
|
||||||
|
conn.commit(); conn.close()
|
||||||
|
return jsonify({'token': token,
|
||||||
|
'url': url_for('client_portal', token=token, _external=True)})
|
||||||
|
|
||||||
|
@app.route('/jobs/<int:id>/revoke-token', methods=['POST'])
|
||||||
|
def revoke_client_token(id):
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute('UPDATE jobs SET client_token=NULL WHERE id=?', (id,))
|
||||||
|
conn.commit(); conn.close()
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
@app.route('/portal/<token>')
|
||||||
|
def client_portal(token):
|
||||||
|
"""Vue publique (sans prix) pour le client."""
|
||||||
|
conn = get_db()
|
||||||
|
job = conn.execute('''
|
||||||
|
SELECT j.*, c.name as client_name, m.name as material_name, m.type as material_type,
|
||||||
|
m.color as material_color
|
||||||
|
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.client_token=?''', (token,)).fetchone()
|
||||||
|
if not job:
|
||||||
|
conn.close()
|
||||||
|
return render_template('portal_404.html'), 404
|
||||||
|
slots = conn.execute('''
|
||||||
|
SELECT ps.planned_start, ps.planned_end, ps.status, ps.failure_note,
|
||||||
|
p.name as printer_name
|
||||||
|
FROM print_slots ps
|
||||||
|
JOIN printers p ON ps.printer_id=p.id
|
||||||
|
WHERE ps.job_id=? ORDER BY ps.planned_start''', (job['id'],)).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return render_template('client_portal.html', job=job, slots=slots)
|
||||||
|
|
||||||
|
@app.route('/api/nextcloud/file')
|
||||||
|
def nc_file():
|
||||||
|
"""Proxy WebDAV : télécharge un fichier Nextcloud et le sert au navigateur."""
|
||||||
|
path = request.args.get('path', '')
|
||||||
|
if not path:
|
||||||
|
return Response('Paramètre path manquant', status=400)
|
||||||
|
sess, base_url, username = _nc_session()
|
||||||
|
if not sess:
|
||||||
|
return Response('Nextcloud non configuré', status=503)
|
||||||
|
dav_url = _dav_url(base_url, username, path)
|
||||||
|
try:
|
||||||
|
r = sess.get(dav_url, timeout=30, stream=True)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return Response(f'Erreur WebDAV {r.status_code}', status=502)
|
||||||
|
filename = path.split('/')[-1]
|
||||||
|
ext = filename.rsplit('.', 1)[-1].lower()
|
||||||
|
mime_map = {
|
||||||
|
'stl': 'model/stl',
|
||||||
|
'3mf': 'model/3mf',
|
||||||
|
'step': 'application/step',
|
||||||
|
'stp': 'application/step',
|
||||||
|
'obj': 'model/obj',
|
||||||
|
}
|
||||||
|
mime = mime_map.get(ext, 'application/octet-stream')
|
||||||
|
return Response(r.content, mimetype=mime,
|
||||||
|
headers={'Content-Disposition': f'inline; filename="{filename}"',
|
||||||
|
'Access-Control-Allow-Origin': '*'})
|
||||||
|
except Exception as e:
|
||||||
|
return Response(str(e), status=500)
|
||||||
|
|
||||||
# ─── Paramètres ───────────────────────────────────────────────────────────────
|
# ─── Paramètres ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ services:
|
|||||||
- 3d-pricing-data:/data
|
- 3d-pricing-data:/data
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/data/pricing.db
|
- DATABASE_PATH=/data/pricing.db
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-changeme_generate_with_openssl_rand_hex_32}
|
||||||
|
# ── OIDC Authelia (laisser vide pour désactiver) ──────────────────
|
||||||
|
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
|
||||||
|
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
|
||||||
|
- OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-}
|
||||||
|
# URL de fin de session Authelia (optionnel)
|
||||||
|
# ex: https://auth.tondomain.com/api/oidc/end-session
|
||||||
|
- OIDC_END_SESSION_URL=${OIDC_END_SESSION_URL:-}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
3d-pricing-data:
|
3d-pricing-data:
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
flask>=3.0.0
|
flask>=3.0.0
|
||||||
|
authlib>=1.3.0
|
||||||
|
requests>=2.31.0
|
||||||
|
|||||||
@@ -104,7 +104,23 @@
|
|||||||
<a href="{{ url_for('settings') }}" class="{% if request.endpoint=='settings' %}active{% endif %}">
|
<a href="{{ url_for('settings') }}" class="{% if request.endpoint=='settings' %}active{% endif %}">
|
||||||
<i class="bi bi-sliders"></i> Paramètres
|
<i class="bi bi-sliders"></i> Paramètres
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('mobile_quick') }}" class="{% if request.endpoint=='mobile_quick' %}active{% endif %}">
|
||||||
|
<i class="bi bi-phone"></i> Vue mobile
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{% if session.user %}
|
||||||
|
<div style="padding:.75rem 1.25rem;border-top:1px solid #2e323d;margin-top:auto">
|
||||||
|
<div style="font-size:.75rem;color:#9ba3af;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
|
||||||
|
title="{{ session.user.email }}">
|
||||||
|
<i class="bi bi-person-circle me-1"></i>{{ session.user.name }}
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('logout') }}"
|
||||||
|
style="font-size:.72rem;color:#6b7280;text-decoration:none;display:block;margin-top:.25rem">
|
||||||
|
<i class="bi bi-box-arrow-left me-1"></i>Déconnexion
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="main">
|
<div id="main">
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Suivi commande — {{ job.name }}</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>
|
||||||
|
body { background: #f4f6f9; }
|
||||||
|
.topbar { background: #1a1d23; color: #fff; padding: 1rem 1.5rem; }
|
||||||
|
.topbar .brand { font-weight: 800; font-size: 1.2rem; }
|
||||||
|
.topbar .brand span { color: #ff6b35; }
|
||||||
|
.card { border: none; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||||
|
.stat-val { font-size: 1.5rem; font-weight: 700; }
|
||||||
|
.timeline { position: relative; padding-left: 2rem; }
|
||||||
|
.timeline::before { content: ''; position: absolute; left: .6rem; top: 0; bottom: 0;
|
||||||
|
width: 2px; background: #dee2e6; }
|
||||||
|
.timeline-item { position: relative; margin-bottom: 1.2rem; }
|
||||||
|
.timeline-item::before { content: ''; position: absolute; left: -1.55rem; top: .3rem;
|
||||||
|
width: 12px; height: 12px; border-radius: 50%; border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 2px #dee2e6; background: #6b7280; }
|
||||||
|
.timeline-item.done::before { background: #22c55e; box-shadow: 0 0 0 2px #22c55e; }
|
||||||
|
.timeline-item.failed::before { background: #ef4444; box-shadow: 0 0 0 2px #ef4444; }
|
||||||
|
.timeline-item.planned::before{ background: #3b82f6; box-shadow: 0 0 0 2px #3b82f6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="topbar d-flex align-items-center justify-content-between">
|
||||||
|
<div class="brand"><i class="bi bi-layers-half me-1"></i>3D<span>Pricing</span></div>
|
||||||
|
<small class="text-secondary">Suivi de commande</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container py-4" style="max-width:720px">
|
||||||
|
|
||||||
|
<!-- En-tête commande -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="fw-bold mb-1">{{ job.name }}</h4>
|
||||||
|
{% if job.client_name %}
|
||||||
|
<div class="text-muted small mb-3"><i class="bi bi-building me-1"></i>{{ job.client_name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-6 col-md-3 text-center">
|
||||||
|
{% set status_map = {'planned':'En cours','done':'Terminé','failed':'Échec','cancelled':'Annulé'} %}
|
||||||
|
{% set slot_statuses = slots | map(attribute='status') | list %}
|
||||||
|
{% if 'done' in slot_statuses %}
|
||||||
|
<span class="badge bg-success fs-6 px-3 py-2">✅ Terminé</span>
|
||||||
|
{% elif 'failed' in slot_statuses %}
|
||||||
|
<span class="badge bg-danger fs-6 px-3 py-2">❌ Incident</span>
|
||||||
|
{% elif slots %}
|
||||||
|
<span class="badge bg-primary fs-6 px-3 py-2">🖨️ En cours</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary fs-6 px-3 py-2">📋 Planifié</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 text-center">
|
||||||
|
<div class="text-muted small">Pièces commandées</div>
|
||||||
|
<div class="stat-val">{{ job.order_qty or 1 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 text-center">
|
||||||
|
<div class="text-muted small">Matière</div>
|
||||||
|
<div class="fw-semibold">
|
||||||
|
{% if job.material_name %}
|
||||||
|
{% if job.material_color %}
|
||||||
|
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;
|
||||||
|
background:{{ job.material_color | css_color }};border:1px solid #ccc;
|
||||||
|
vertical-align:middle;margin-right:4px"></span>
|
||||||
|
{% endif %}
|
||||||
|
{{ job.material_name }}
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 text-center">
|
||||||
|
<div class="text-muted small">Créé le</div>
|
||||||
|
<div class="fw-semibold">{{ job.created_at[:10] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if job.description %}
|
||||||
|
<hr class="my-3">
|
||||||
|
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>{{ job.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline créneaux -->
|
||||||
|
{% if slots %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-bold mb-3"><i class="bi bi-printer me-2"></i>Suivi d'impression</h6>
|
||||||
|
<div class="timeline">
|
||||||
|
{% for s in slots %}
|
||||||
|
<div class="timeline-item {{ s.status }}">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">{{ s.printer_name }}</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
{{ s.planned_start[:16].replace('T', ' ') }} →
|
||||||
|
{{ s.planned_end[11:16] }}
|
||||||
|
</div>
|
||||||
|
{% if s.failure_note %}
|
||||||
|
<div class="small text-danger mt-1">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>{{ s.failure_note }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="badge
|
||||||
|
{% if s.status == 'done' %}bg-success
|
||||||
|
{% elif s.status == 'failed' %}bg-danger
|
||||||
|
{% elif s.status == 'planned' %}bg-primary
|
||||||
|
{% else %}bg-secondary{% endif %} ms-2">
|
||||||
|
{{ {'done':'Terminé','failed':'Échec','planned':'Planifié','cancelled':'Annulé'}.get(s.status, s.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body text-center text-muted py-4">
|
||||||
|
<i class="bi bi-calendar-plus fs-2 d-block mb-2"></i>
|
||||||
|
Impression non encore planifiée.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="text-center text-muted small">
|
||||||
|
<i class="bi bi-shield-lock me-1"></i>
|
||||||
|
Cette page est partagée uniquement pour le suivi de votre commande.
|
||||||
|
Les informations de tarification ne sont pas visibles.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+249
-2
@@ -9,7 +9,20 @@
|
|||||||
</a>
|
</a>
|
||||||
<h1 class="mt-1">{{ job.name }}</h1>
|
<h1 class="mt-1">{{ job.name }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" id="shareBtn"
|
||||||
|
title="{{ 'Lien client actif — cliquer pour copier' if job.client_token else 'Générer un lien client' }}">
|
||||||
|
{% if job.client_token %}
|
||||||
|
<i class="bi bi-share-fill me-1 text-success"></i>Lien client
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-share me-1"></i>Partager
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
{% if job.client_token %}
|
||||||
|
<button class="btn btn-sm btn-outline-danger" id="revokeBtn" title="Révoquer le lien">
|
||||||
|
<i class="bi bi-x-circle"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
<a href="{{ url_for('new_job', clone_from=job.id) }}" class="btn btn-sm btn-outline-secondary">
|
<a href="{{ url_for('new_job', clone_from=job.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="bi bi-copy me-1"></i>Dupliquer
|
<i class="bi bi-copy me-1"></i>Dupliquer
|
||||||
</a>
|
</a>
|
||||||
@@ -38,7 +51,20 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><th class="text-muted fw-normal">Fichier source</th>
|
<tr><th class="text-muted fw-normal">Fichier source</th>
|
||||||
<td class="small font-monospace">{{ job.source_file or '—' }}</td></tr>
|
<td class="small">
|
||||||
|
{% if job.source_file %}
|
||||||
|
<span class="font-monospace">{{ job.source_file.split('/')[-1] }}</span>
|
||||||
|
{% set ext = job.source_file.rsplit('.',1)[-1].lower() %}
|
||||||
|
{% if ext in ['stl','3mf','step','stp','obj'] %}
|
||||||
|
<button class="btn btn-xs btn-outline-secondary py-0 px-1 ms-1"
|
||||||
|
style="font-size:.7rem"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#viewerModal"
|
||||||
|
data-file="{{ job.source_file }}" data-ext="{{ ext }}">
|
||||||
|
<i class="bi bi-box me-1"></i>Voir
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</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">Date</th><td>{{ job.created_at[:10] }}</td></tr>
|
||||||
<tr><th class="text-muted fw-normal">Poids plateau</th><td>{{ job.weight_g }} g</td></tr>
|
<tr><th class="text-muted fw-normal">Poids plateau</th><td>{{ job.weight_g }} g</td></tr>
|
||||||
<tr><th class="text-muted fw-normal">Pieces / plateau</th><td>{{ job.pieces_per_plate or 1 }}</td></tr>
|
<tr><th class="text-muted fw-normal">Pieces / plateau</th><td>{{ job.pieces_per_plate or 1 }}</td></tr>
|
||||||
@@ -207,4 +233,225 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Modale viewer 3D ──────────────────────────────────────────────────── -->
|
||||||
|
<div class="modal fade" id="viewerModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||||
|
<div class="modal-content" style="background:#1a1d23">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<span class="text-white fw-semibold" id="viewerTitle">Viewer 3D</span>
|
||||||
|
<div class="ms-auto d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="viewerWire">Wireframe</button>
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="viewerReset">Reset vue</button>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0 position-relative" style="height:75vh">
|
||||||
|
<canvas id="viewerCanvas" style="width:100%;height:100%;display:block"></canvas>
|
||||||
|
<div id="viewerLoader" class="position-absolute top-50 start-50 translate-middle text-white text-center">
|
||||||
|
<div class="spinner-border mb-2"></div><br>Chargement du modèle…
|
||||||
|
</div>
|
||||||
|
<div id="viewerError" class="position-absolute top-50 start-50 translate-middle text-danger text-center" style="display:none">
|
||||||
|
<i class="bi bi-exclamation-triangle fs-1 d-block mb-2"></i>
|
||||||
|
<span id="viewerErrorMsg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
||||||
|
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="module">
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
||||||
|
import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
|
||||||
|
|
||||||
|
const modal = document.getElementById('viewerModal');
|
||||||
|
const canvas = document.getElementById('viewerCanvas');
|
||||||
|
const loader = document.getElementById('viewerLoader');
|
||||||
|
const errDiv = document.getElementById('viewerError');
|
||||||
|
const errMsg = document.getElementById('viewerErrorMsg');
|
||||||
|
const title = document.getElementById('viewerTitle');
|
||||||
|
|
||||||
|
let renderer, scene, camera, controls, modelMesh;
|
||||||
|
let wireMode = false;
|
||||||
|
|
||||||
|
function initRenderer() {
|
||||||
|
if (renderer) return;
|
||||||
|
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.setClearColor(0x1a1d23, 1);
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 10000);
|
||||||
|
|
||||||
|
controls = new OrbitControls(camera, canvas);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.08;
|
||||||
|
|
||||||
|
// Lumières
|
||||||
|
const amb = new THREE.AmbientLight(0xffffff, 0.5);
|
||||||
|
const dir = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||||
|
dir.position.set(5, 10, 7);
|
||||||
|
scene.add(amb, dir);
|
||||||
|
|
||||||
|
// Grille
|
||||||
|
const grid = new THREE.GridHelper(300, 30, 0x444444, 0x333333);
|
||||||
|
scene.add(grid);
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
controls.update();
|
||||||
|
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
|
||||||
|
camera.aspect = canvas.clientWidth / canvas.clientHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitCamera() {
|
||||||
|
if (!modelMesh) return;
|
||||||
|
const box = new THREE.Box3().setFromObject(modelMesh);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
camera.position.set(center.x + maxDim * 1.5, center.y + maxDim, center.z + maxDim * 1.5);
|
||||||
|
controls.target.copy(center);
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadModel(path, ext) {
|
||||||
|
loader.style.display = '';
|
||||||
|
errDiv.style.display = 'none';
|
||||||
|
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
|
||||||
|
|
||||||
|
const url = '/api/nextcloud/file?path=' + encodeURIComponent(path);
|
||||||
|
const mat = new THREE.MeshPhongMaterial({ color: 0xff6b35, specular: 0x333333, shininess: 40, side: THREE.DoubleSide });
|
||||||
|
|
||||||
|
const onError = (e) => {
|
||||||
|
loader.style.display = 'none';
|
||||||
|
errDiv.style.display = '';
|
||||||
|
errMsg.textContent = 'Erreur chargement : ' + (e.message || e);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ext === 'stl') {
|
||||||
|
new STLLoader().load(url, geo => {
|
||||||
|
geo.computeVertexNormals();
|
||||||
|
modelMesh = new THREE.Mesh(geo, mat);
|
||||||
|
modelMesh.rotation.x = -Math.PI / 2;
|
||||||
|
scene.add(modelMesh);
|
||||||
|
loader.style.display = 'none';
|
||||||
|
fitCamera();
|
||||||
|
}, undefined, onError);
|
||||||
|
|
||||||
|
} else if (ext === '3mf') {
|
||||||
|
new ThreeMFLoader().load(url, obj => {
|
||||||
|
obj.traverse(child => {
|
||||||
|
if (child.isMesh) child.material = mat.clone();
|
||||||
|
});
|
||||||
|
modelMesh = obj;
|
||||||
|
scene.add(modelMesh);
|
||||||
|
loader.style.display = 'none';
|
||||||
|
fitCamera();
|
||||||
|
}, undefined, onError);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
loader.style.display = 'none';
|
||||||
|
errDiv.style.display = '';
|
||||||
|
errMsg.textContent = `Format .${ext} non supporté dans le viewer (STL et 3MF uniquement).`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ouvrir la modale
|
||||||
|
modal.addEventListener('show.bs.modal', e => {
|
||||||
|
const btn = e.relatedTarget;
|
||||||
|
const path = btn.dataset.file;
|
||||||
|
const ext = btn.dataset.ext;
|
||||||
|
title.textContent = path.split('/').pop();
|
||||||
|
initRenderer();
|
||||||
|
loadModel(path, ext);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fermer → libérer
|
||||||
|
modal.addEventListener('hidden.bs.modal', () => {
|
||||||
|
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wireframe toggle
|
||||||
|
document.getElementById('viewerWire').addEventListener('click', function () {
|
||||||
|
wireMode = !wireMode;
|
||||||
|
this.classList.toggle('active', wireMode);
|
||||||
|
if (modelMesh) modelMesh.traverse(c => {
|
||||||
|
if (c.isMesh) c.material.wireframe = wireMode;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset vue
|
||||||
|
document.getElementById('viewerReset').addEventListener('click', fitCamera);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Partage client ───────────────────────────────────────────────────────────
|
||||||
|
const jobId = {{ job.id }};
|
||||||
|
const shareBtn = document.getElementById('shareBtn');
|
||||||
|
const revokeBtn= document.getElementById('revokeBtn');
|
||||||
|
let currentToken = {{ '"' + job.client_token + '"' if job.client_token else 'null' }};
|
||||||
|
|
||||||
|
if (shareBtn) {
|
||||||
|
shareBtn.addEventListener('click', async () => {
|
||||||
|
if (currentToken) {
|
||||||
|
// Copier l'URL existante
|
||||||
|
const url = location.origin + '/portal/' + currentToken;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
shareBtn.innerHTML = '<i class="bi bi-check-circle-fill me-1 text-success"></i>Copié !';
|
||||||
|
setTimeout(() => {
|
||||||
|
shareBtn.innerHTML = '<i class="bi bi-share-fill me-1 text-success"></i>Lien client';
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
// Générer un nouveau token
|
||||||
|
const r = await fetch(`/jobs/${jobId}/generate-token`, {method:'POST'});
|
||||||
|
const d = await r.json();
|
||||||
|
currentToken = d.token;
|
||||||
|
await navigator.clipboard.writeText(d.url);
|
||||||
|
shareBtn.innerHTML = '<i class="bi bi-check-circle-fill me-1 text-success"></i>Lien copié !';
|
||||||
|
setTimeout(() => {
|
||||||
|
shareBtn.innerHTML = '<i class="bi bi-share-fill me-1 text-success"></i>Lien client';
|
||||||
|
}, 2500);
|
||||||
|
// Ajouter bouton révoquer
|
||||||
|
if (!document.getElementById('revokeBtn')) {
|
||||||
|
const rb = document.createElement('button');
|
||||||
|
rb.id = 'revokeBtn';
|
||||||
|
rb.className = 'btn btn-sm btn-outline-danger';
|
||||||
|
rb.title = 'Révoquer le lien';
|
||||||
|
rb.innerHTML = '<i class="bi bi-x-circle"></i>';
|
||||||
|
shareBtn.insertAdjacentElement('afterend', rb);
|
||||||
|
rb.addEventListener('click', revokeLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeLink() {
|
||||||
|
if (!confirm('Révoquer le lien client ? Il ne sera plus accessible.')) return;
|
||||||
|
await fetch(`/jobs/${jobId}/revoke-token`, {method:'POST'});
|
||||||
|
currentToken = null;
|
||||||
|
shareBtn.innerHTML = '<i class="bi bi-share me-1"></i>Partager';
|
||||||
|
shareBtn.title = 'Générer un lien client';
|
||||||
|
document.getElementById('revokeBtn')?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (revokeBtn) revokeBtn.addEventListener('click', revokeLink);
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Connexion — 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>
|
||||||
|
body { background: #f4f6f9; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||||
|
.login-card { background: #fff; border-radius: 14px; box-shadow: 0 4px 24px rgba(0,0,0,.08); padding: 2.5rem 2rem; width: 100%; max-width: 380px; text-align: center; }
|
||||||
|
.brand { font-size: 1.6rem; font-weight: 800; color: #1a1d23; margin-bottom: .25rem; }
|
||||||
|
.brand span { color: #ff6b35; }
|
||||||
|
.subtitle { color: #6b7280; font-size: .875rem; margin-bottom: 2rem; }
|
||||||
|
.btn-authelia { background: #ff6b35; color: #fff; border: none; border-radius: 8px; padding: .75rem 1.5rem; font-size: 1rem; font-weight: 600; width: 100%; transition: background .15s; }
|
||||||
|
.btn-authelia:hover { background: #e85d2d; color: #fff; }
|
||||||
|
.powered { font-size: .75rem; color: #9ca3af; margin-top: 1.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="brand"><i class="bi bi-layers-half"></i> 3D<span>Pricing</span></div>
|
||||||
|
<div class="subtitle">Gestion & tarification d'impressions 3D</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ cat }} mb-3 text-start">{{ msg }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<a href="{{ url_for('login') }}" class="btn btn-authelia">
|
||||||
|
<i class="bi bi-shield-lock me-2"></i>Se connecter avec Authelia
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="powered">
|
||||||
|
Authentification sécurisée via SSO
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<title>3D Pricing — Mobile</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>
|
||||||
|
body { background: #f4f6f9; padding-bottom: 2rem; }
|
||||||
|
.topbar {
|
||||||
|
background: #1a1d23; color: #fff; padding: .75rem 1rem;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
position: sticky; top: 0; z-index: 100;
|
||||||
|
}
|
||||||
|
.topbar .brand { font-weight: 700; font-size: 1.1rem; }
|
||||||
|
.topbar .brand span { color: #ff6b35; }
|
||||||
|
.section-title {
|
||||||
|
font-size: .7rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: .08em; color: #6b7280; padding: 1rem 1rem .4rem;
|
||||||
|
}
|
||||||
|
.slot-card {
|
||||||
|
background: #fff; border-radius: 10px; padding: .9rem 1rem;
|
||||||
|
margin: .4rem .75rem; box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
.slot-card .job-name { font-weight: 600; font-size: .95rem; }
|
||||||
|
.slot-card .meta { font-size: .78rem; color: #6b7280; }
|
||||||
|
.slot-card .time { font-size: .82rem; color: #374151; }
|
||||||
|
.tab-bar {
|
||||||
|
display: flex; background: #fff; border-bottom: 2px solid #f0f0f0;
|
||||||
|
position: sticky; top: 53px; z-index: 99;
|
||||||
|
}
|
||||||
|
.tab-bar button {
|
||||||
|
flex: 1; border: none; background: none; padding: .7rem .5rem;
|
||||||
|
font-size: .8rem; color: #6b7280; font-weight: 600;
|
||||||
|
border-bottom: 2px solid transparent; margin-bottom: -2px;
|
||||||
|
transition: color .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.tab-bar button.active { color: #ff6b35; border-bottom-color: #ff6b35; }
|
||||||
|
.tab-pane { display: none; }
|
||||||
|
.tab-pane.active { display: block; }
|
||||||
|
.form-card { background: #fff; border-radius: 10px; margin: .75rem; padding: 1rem; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||||
|
.btn-primary-orange { background: #ff6b35; color: #fff; border: none; border-radius: 8px; }
|
||||||
|
.btn-primary-orange:hover { background: #e85d2d; color: #fff; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="brand"><i class="bi bi-layers-half me-1"></i>3D<span>Pricing</span></div>
|
||||||
|
<a href="/" class="btn btn-sm btn-outline-light btn-sm">
|
||||||
|
<i class="bi bi-grid me-1"></i>Bureau
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button class="active" onclick="showTab('planning', this)">
|
||||||
|
<i class="bi bi-calendar3 d-block mb-1"></i>Planning
|
||||||
|
</button>
|
||||||
|
<button onclick="showTab('block', this)">
|
||||||
|
<i class="bi bi-calendar-x d-block mb-1"></i>Indispo
|
||||||
|
</button>
|
||||||
|
<button onclick="showTab('shift', this)">
|
||||||
|
<i class="bi bi-arrow-left-right d-block mb-1"></i>Décaler
|
||||||
|
</button>
|
||||||
|
<button onclick="showTab('fail', this)">
|
||||||
|
<i class="bi bi-exclamation-triangle d-block mb-1"></i>Échec
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ cat }} mx-3 mt-2 mb-0 py-2 small">{{ msg }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- ── Tab : Planning ─────────────────────────────────── -->
|
||||||
|
<div class="tab-pane active" id="tab-planning">
|
||||||
|
<div class="section-title">Prochaines impressions</div>
|
||||||
|
{% if upcoming %}
|
||||||
|
{% for s in upcoming %}
|
||||||
|
<div class="slot-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<div class="job-name">{{ s.job_name }}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<i class="bi bi-printer me-1"></i>{{ s.printer_name }}
|
||||||
|
{% if s.client_name %} · {{ s.client_name }}{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="time mt-1">
|
||||||
|
<i class="bi bi-clock me-1"></i>
|
||||||
|
{{ s.planned_start[:16].replace('T',' ') }} →
|
||||||
|
{{ s.planned_end[11:16] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge {% if s.status=='done' %}bg-success{% elif s.status=='failed' %}bg-danger{% else %}bg-secondary{% endif %}">
|
||||||
|
{{ s.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-calendar-check fs-1 d-block mb-2"></i>
|
||||||
|
Aucune impression planifiée.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Tab : Indisponibilité ──────────────────────────── -->
|
||||||
|
<div class="tab-pane" id="tab-block">
|
||||||
|
<div class="section-title">Ajouter une indisponibilité</div>
|
||||||
|
<div class="form-card">
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="block">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Date</label>
|
||||||
|
<input type="date" name="date" class="form-control" value="{{ today }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Type</label>
|
||||||
|
<select name="type" class="form-select">
|
||||||
|
<option value="off">🔴 Jour off (toutes machines)</option>
|
||||||
|
<option value="maintenance">🔧 Maintenance</option>
|
||||||
|
<option value="other">📌 Autre</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Machine (optionnel)</label>
|
||||||
|
<select name="printer_id" class="form-select">
|
||||||
|
<option value="">Toutes les machines</option>
|
||||||
|
{% for p in printers %}
|
||||||
|
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Note</label>
|
||||||
|
<input type="text" name="notes" class="form-control" placeholder="Raison…">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary-orange w-100">
|
||||||
|
<i class="bi bi-calendar-plus me-1"></i>Ajouter
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Tab : Décaler ──────────────────────────────────── -->
|
||||||
|
<div class="tab-pane" id="tab-shift">
|
||||||
|
<div class="section-title">Décaler un créneau</div>
|
||||||
|
<div class="form-card">
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="shift">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Créneau à décaler</label>
|
||||||
|
<select name="slot_id" class="form-select" required>
|
||||||
|
<option value="">Choisir…</option>
|
||||||
|
{% for s in upcoming %}
|
||||||
|
<option value="{{ s.id }}">
|
||||||
|
{{ s.job_name }} — {{ s.planned_start[:16].replace('T',' ') }} [{{ s.printer_name }}]
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Nouveau début</label>
|
||||||
|
<input type="datetime-local" name="new_start" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary-orange w-100">
|
||||||
|
<i class="bi bi-arrow-left-right me-1"></i>Décaler
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Tab : Échec ────────────────────────────────────── -->
|
||||||
|
<div class="tab-pane" id="tab-fail">
|
||||||
|
<div class="section-title">Signaler un échec d'impression</div>
|
||||||
|
<div class="form-card">
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="fail">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Créneau concerné</label>
|
||||||
|
<select name="slot_id" class="form-select" required>
|
||||||
|
<option value="">Choisir…</option>
|
||||||
|
{% for s in upcoming %}
|
||||||
|
<option value="{{ s.id }}">
|
||||||
|
{{ s.job_name }} — {{ s.planned_start[:16].replace('T',' ') }} [{{ s.printer_name }}]
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Description du problème</label>
|
||||||
|
<textarea name="failure_note" class="form-control" rows="3"
|
||||||
|
placeholder="Détachement, sous-extrusion, bourrage…"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-danger w-100">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>Signaler l'échec
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
function showTab(name, btn) {
|
||||||
|
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-bar button').forEach(b => b.classList.remove('active'));
|
||||||
|
document.getElementById('tab-' + name).classList.add('active');
|
||||||
|
btn.classList.add('active');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+183
-13
@@ -50,9 +50,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Fichier source</label>
|
<label class="form-label fw-semibold">Fichier source</label>
|
||||||
<input type="text" name="source_file" id="sourceFile" class="form-control"
|
<div class="input-group">
|
||||||
placeholder="nom_fichier.3mf"
|
<input type="text" name="source_file" id="sourceFile" class="form-control"
|
||||||
value="{{ prefill.source_file if prefill else '' }}">
|
placeholder="nom_fichier.3mf"
|
||||||
|
value="{{ prefill.source_file if prefill else '' }}">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="ncBrowseBtn"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#ncPickerModal"
|
||||||
|
title="Parcourir Nextcloud">
|
||||||
|
<i class="bi bi-cloud-arrow-down"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
@@ -282,35 +289,109 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modale Nextcloud File Picker -->
|
||||||
|
<div class="modal fade" id="ncPickerModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="flex-fill">
|
||||||
|
<h5 class="modal-title mb-1"><i class="bi bi-cloud me-2"></i>Nextcloud — Choisir un fichier</h5>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-0 small" id="ncBreadcrumb"></ol>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0" id="ncPickerBody" style="max-height:60vh;overflow-y:auto">
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-cloud fs-1 d-block mb-2"></i>
|
||||||
|
Cliquez sur le bouton <i class="bi bi-cloud-arrow-down"></i> pour ouvrir.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer py-2">
|
||||||
|
<small class="text-muted me-auto">Cliquez sur un fichier pour le sélectionner.</small>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
// ── 3MF parser ────────────────────────────────────────────────────────────────
|
// ── 3MF parser avec sélecteur de plaque ──────────────────────────────────────
|
||||||
|
let _3mfPlates = [];
|
||||||
|
|
||||||
function parse3mf() {
|
function parse3mf() {
|
||||||
const file = document.getElementById('file3mf').files[0];
|
const file = document.getElementById('file3mf').files[0];
|
||||||
if (!file) { alert('Selectionnez un fichier .3mf'); return; }
|
if (!file) { alert('Selectionnez un fichier .3mf'); return; }
|
||||||
const status = document.getElementById('parseStatus');
|
const status = document.getElementById('parseStatus');
|
||||||
status.textContent = 'Analyse...';
|
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyse…';
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
fetch('/api/parse-3mf', {method:'POST', body:fd})
|
fetch('/api/parse-3mf', {method:'POST', body:fd})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => {
|
.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) {
|
if (d.filename) {
|
||||||
document.getElementById('sourceFile').value = d.filename;
|
document.getElementById('sourceFile').value = d.filename;
|
||||||
if (!document.getElementById('jobName').value)
|
if (!document.getElementById('jobName').value)
|
||||||
document.getElementById('jobName').value = d.filename;
|
document.getElementById('jobName').value = d.filename;
|
||||||
}
|
}
|
||||||
status.textContent = filled.length ? 'Extrait: ' + filled.join(', ') : 'Donnees non trouvees';
|
// ── Cas Bambu avec plaques détectées ──
|
||||||
status.style.color = filled.length ? '#22c55e' : '#f59e0b';
|
if (d.plates && d.plates.length > 1) {
|
||||||
recalc();
|
_3mfPlates = d.plates;
|
||||||
|
renderPlateSelector(d.plates);
|
||||||
|
status.innerHTML = `<span class="text-success">${d.plates.length} plateau(x) détecté(s) — choisissez ci-dessous</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ── Plaque unique ou fallback ──
|
||||||
|
applyPlateData(d.weight_g, d.hours, d.minutes);
|
||||||
|
const ok = d.weight_g || d.hours != null;
|
||||||
|
status.innerHTML = ok
|
||||||
|
? '<span class="text-success"><i class="bi bi-check-circle me-1"></i>Données extraites</span>'
|
||||||
|
: '<span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>Données non trouvées</span>';
|
||||||
})
|
})
|
||||||
.catch(() => { status.textContent = 'Erreur de lecture'; status.style.color = '#ef4444'; });
|
.catch(() => { status.innerHTML = '<span class="text-danger">Erreur de lecture</span>'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPlateData(weight_g, hours, minutes) {
|
||||||
|
if (weight_g != null) document.getElementById('weight_g').value = weight_g;
|
||||||
|
if (hours != null) document.getElementById('hours').value = hours;
|
||||||
|
if (minutes != null) document.getElementById('minutes').value = minutes;
|
||||||
|
recalc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlateSelector(plates) {
|
||||||
|
// Supprimer un éventuel sélecteur précédent
|
||||||
|
document.getElementById('plateSelectorWrap')?.remove();
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.id = 'plateSelectorWrap';
|
||||||
|
wrap.className = 'mt-3 border rounded p-3 bg-light';
|
||||||
|
wrap.innerHTML = '<div class="fw-semibold small mb-2"><i class="bi bi-layers me-1"></i>Choisir le plateau à imprimer :</div>';
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'd-flex flex-wrap gap-2';
|
||||||
|
plates.forEach(p => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn btn-sm btn-outline-warning';
|
||||||
|
// Pastilles filaments
|
||||||
|
const dots = p.filaments.map(f =>
|
||||||
|
`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${f.color || '#ccc'};border:1px solid #aaa;margin-right:2px"></span>`
|
||||||
|
).join('');
|
||||||
|
btn.innerHTML = `${dots}<strong>Plateau ${p.index}</strong> — ${p.weight_g} g — ${Math.floor(p.print_time_s/3600)}h${String(Math.floor((p.print_time_s%3600)/60)).padStart(2,'0')}m`;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('#plateSelectorWrap button').forEach(b => b.classList.replace('btn-warning','btn-outline-warning'));
|
||||||
|
btn.classList.replace('btn-outline-warning','btn-warning');
|
||||||
|
applyPlateData(p.weight_g, Math.floor(p.print_time_s/3600), Math.floor((p.print_time_s%3600)/60));
|
||||||
|
document.getElementById('parseStatus').innerHTML =
|
||||||
|
`<span class="text-success"><i class="bi bi-check-circle me-1"></i>Plateau ${p.index} appliqué</span>`;
|
||||||
|
});
|
||||||
|
grid.appendChild(btn);
|
||||||
|
});
|
||||||
|
wrap.appendChild(grid);
|
||||||
|
// Insérer après le card d'import
|
||||||
|
document.querySelector('.card.border-warning').insertAdjacentElement('afterend', wrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Coefficient design ────────────────────────────────────────────────────────
|
// ── Coefficient design ────────────────────────────────────────────────────────
|
||||||
@@ -595,5 +676,94 @@ const _initMargin = parseFloat(document.getElementById('gross_margin_pct').value
|
|||||||
updateDMLabel(_initDM);
|
updateDMLabel(_initDM);
|
||||||
updateMarginDisplay(_initMargin);
|
updateMarginDisplay(_initMargin);
|
||||||
if (document.getElementById('weight_g').value) recalc();
|
if (document.getElementById('weight_g').value) recalc();
|
||||||
|
|
||||||
|
// ── Nextcloud File Picker ─────────────────────────────────────────────────────
|
||||||
|
(function () {
|
||||||
|
const modal = document.getElementById('ncPickerModal');
|
||||||
|
const body = document.getElementById('ncPickerBody');
|
||||||
|
const bread = document.getElementById('ncBreadcrumb');
|
||||||
|
const sfInput = document.getElementById('sourceFile');
|
||||||
|
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
let currentPath = '';
|
||||||
|
|
||||||
|
async function browse(path) {
|
||||||
|
body.innerHTML = '<div class="text-center py-4"><div class="spinner-border spinner-border-sm"></div> Chargement…</div>';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/nextcloud/browse?path=' + encodeURIComponent(path));
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) {
|
||||||
|
body.innerHTML = `<div class="alert alert-danger m-3">${d.error}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentPath = d.path;
|
||||||
|
renderBreadcrumb(d.breadcrumb);
|
||||||
|
renderItems(d.items);
|
||||||
|
} catch (e) {
|
||||||
|
body.innerHTML = `<div class="alert alert-danger m-3">${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBreadcrumb(crumbs) {
|
||||||
|
bread.innerHTML = crumbs.map((c, i) =>
|
||||||
|
i < crumbs.length - 1
|
||||||
|
? `<li class="breadcrumb-item"><a href="#" class="nc-crumb" data-path="${c.path}">${c.name}</a></li>`
|
||||||
|
: `<li class="breadcrumb-item active">${c.name}</li>`
|
||||||
|
).join('');
|
||||||
|
bread.querySelectorAll('.nc-crumb').forEach(a =>
|
||||||
|
a.addEventListener('click', e => { e.preventDefault(); browse(a.dataset.path); }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXT_ICONS = {
|
||||||
|
'3mf': 'bi-box-seam text-warning',
|
||||||
|
'stl': 'bi-triangle text-info',
|
||||||
|
'step': 'bi-bezier2 text-success',
|
||||||
|
'stp': 'bi-bezier2 text-success',
|
||||||
|
'obj': 'bi-grid-3x3 text-secondary',
|
||||||
|
'gcode':'bi-code-square text-danger',
|
||||||
|
'dir': 'bi-folder-fill text-warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderItems(items) {
|
||||||
|
if (!items.length) {
|
||||||
|
body.innerHTML = '<div class="text-center text-muted py-4">Dossier vide.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = '';
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'list-group list-group-flush';
|
||||||
|
items.forEach(item => {
|
||||||
|
const icon = EXT_ICONS[item.ext] || 'bi-file text-muted';
|
||||||
|
const size = item.is_dir ? '' : ` <small class="text-muted">(${(item.size/1024/1024).toFixed(1)} Mo)</small>`;
|
||||||
|
const el = document.createElement('button');
|
||||||
|
el.type = 'button';
|
||||||
|
el.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2';
|
||||||
|
el.innerHTML = `<i class="bi ${icon} fs-5" style="flex-shrink:0"></i>
|
||||||
|
<span class="flex-fill text-start">${item.name}${size}</span>
|
||||||
|
${item.is_dir ? '<i class="bi bi-chevron-right text-muted"></i>' : '<i class="bi bi-check2-circle text-success"></i>'}`;
|
||||||
|
if (item.is_dir) {
|
||||||
|
el.addEventListener('click', () => browse(item.path));
|
||||||
|
} else {
|
||||||
|
el.addEventListener('click', () => selectFile(item));
|
||||||
|
}
|
||||||
|
list.appendChild(el);
|
||||||
|
});
|
||||||
|
body.appendChild(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFile(item) {
|
||||||
|
sfInput.value = item.path;
|
||||||
|
// Auto-remplir le nom de commande si vide
|
||||||
|
const nameField = document.getElementById('jobName');
|
||||||
|
if (!nameField.value) {
|
||||||
|
nameField.value = item.name.replace(/\.[^.]+$/, '');
|
||||||
|
}
|
||||||
|
bootstrap.Modal.getInstance(modal).hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ouvrir = charger le dossier racine
|
||||||
|
modal.addEventListener('show.bs.modal', () => { if (!currentPath) browse(''); });
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Commande introuvable</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>body { background:#f4f6f9; display:flex; align-items:center; justify-content:center; min-height:100vh; }</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="bi bi-question-circle fs-1 text-muted d-block mb-3"></i>
|
||||||
|
<h4>Commande introuvable</h4>
|
||||||
|
<p class="text-muted">Ce lien de suivi est invalide ou a été révoqué.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -194,6 +194,83 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header py-3"><i class="bi bi-cloud me-2"></i>Nextcloud — Intégration fichiers 3D</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold">URL Nextcloud</label>
|
||||||
|
<input type="url" name="nc_url" class="form-control"
|
||||||
|
placeholder="https://nextcloud.tondomain.com"
|
||||||
|
value="{{ settings.get('nc_url','') }}">
|
||||||
|
<div class="form-text">Sans slash final.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-semibold">Identifiant</label>
|
||||||
|
<input type="text" name="nc_username" class="form-control"
|
||||||
|
placeholder="ton_login"
|
||||||
|
value="{{ settings.get('nc_username','') }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-semibold">Mot de passe d'application</label>
|
||||||
|
<input type="password" name="nc_app_password" class="form-control"
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
value="{{ settings.get('nc_app_password','') }}">
|
||||||
|
<div class="form-text">Générer dans Nextcloud → Paramètres → Sécurité.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold">Dossier racine 3D</label>
|
||||||
|
<input type="text" name="nc_root_path" class="form-control"
|
||||||
|
placeholder="/Impressions3D"
|
||||||
|
value="{{ settings.get('nc_root_path','/3D') }}">
|
||||||
|
<div class="form-text">Chemin de base pour l'explorateur de fichiers.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 d-flex align-items-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="testNcBtn">
|
||||||
|
<i class="bi bi-plug me-1"></i>Tester la connexion
|
||||||
|
</button>
|
||||||
|
<span id="testNcResult" class="ms-3 small"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- iCal -->
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header py-3"><i class="bi bi-calendar-check me-2"></i>Calendrier iCal — Abonnement Android / iOS</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold">Token secret (optionnel)</label>
|
||||||
|
<input type="text" name="ical_token" class="form-control font-monospace"
|
||||||
|
placeholder="Laisser vide = pas de protection"
|
||||||
|
value="{{ settings.get('ical_token','') }}">
|
||||||
|
<div class="form-text">Si renseigné, l'URL contiendra <code>?token=xxx</code>.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label fw-semibold">URL d'abonnement</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="icalUrl" class="form-control font-monospace small" readonly
|
||||||
|
value="{{ request.host_url }}calendar.ics{% if settings.get('ical_token','') %}?token={{ settings.get('ical_token','') }}{% endif %}">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="copyIcal" title="Copier">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
Dans Google Calendar → Autres agendas → Depuis une URL.
|
||||||
|
Samsung / iOS : Paramètres → Comptes → Ajouter → Calendrier.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
@@ -203,3 +280,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.getElementById('copyIcal').addEventListener('click', function () {
|
||||||
|
const url = document.getElementById('icalUrl').value;
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
this.innerHTML = '<i class="bi bi-check text-success"></i>';
|
||||||
|
setTimeout(() => { this.innerHTML = '<i class="bi bi-clipboard"></i>'; }, 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('testNcBtn').addEventListener('click', async function () {
|
||||||
|
const res = document.getElementById('testNcResult');
|
||||||
|
res.textContent = 'Test en cours…';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/nextcloud/browse');
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) {
|
||||||
|
res.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${d.error}</span>`;
|
||||||
|
} else {
|
||||||
|
res.innerHTML = `<span class="text-success"><i class="bi bi-check-circle me-1"></i>Connexion OK — ${d.items.length} élément(s) dans ${d.path}</span>`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
res.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${e.message}</span>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user