10 Commits

8 changed files with 683 additions and 161 deletions

2
app.py
View File

@@ -28,7 +28,7 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)
# 1. Define the version
APP_VERSION = '0.18.0'
APP_VERSION = '0.18.4'
# 2. Inject it into all templates automatically
@app.context_processor

View File

@@ -116,9 +116,9 @@ def execute_pipeline(actions, barcode, context):
placeholders = ', '.join(['?'] * len(cols))
sql = f"INSERT INTO {context['table_name']} ({', '.join(cols)}) VALUES ({placeholders})"
execute_db(sql, vals)
new_id = execute_db(sql, vals)
return {'success': True, 'message': 'Saved Successfully', 'data': field_values}
return {'success': True, 'message': 'Saved Successfully', 'detail_id': new_id, 'data': field_values}
except Exception as e:
return {'success': False, 'message': f"Database Error: {str(e)}", 'data': field_values}
else:

View File

@@ -148,10 +148,46 @@ def get_migrations():
''')
print(" Created cons_process_router table")
def migration_006_add_deleted_status(conn):
"""Add 'deleted' to the status CHECK constraint"""
cursor = conn.cursor()
# SQLite doesn't support ALTER COLUMN, so we need to recreate the table
# First, create a new table with the updated constraint
cursor.execute('''
CREATE TABLE cons_sessions_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
process_id INTEGER NOT NULL,
created_by INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'archived', 'deleted')),
FOREIGN KEY (process_id) REFERENCES cons_processes(id),
FOREIGN KEY (created_by) REFERENCES Users(user_id)
)
''')
# Copy data from old table
cursor.execute('''
INSERT INTO cons_sessions_new (id, process_id, created_by, created_at, status)
SELECT id, process_id, created_by, created_at, status
FROM cons_sessions
''')
# Drop old table and rename new one
cursor.execute('DROP TABLE cons_sessions')
cursor.execute('ALTER TABLE cons_sessions_new RENAME TO cons_sessions')
# Recreate indexes
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_sessions_process ON cons_sessions(process_id, status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_sessions_user ON cons_sessions(created_by, status)')
print(" Updated cons_sessions status constraint to include 'deleted'")
return [
(1, 'add_is_duplicate_key', migration_001_add_is_duplicate_key),
(2, 'add_detail_end_row', migration_002_add_detail_end_row),
(3, 'add_page_height', migration_003_add_page_height),
(4, 'add_print_columns', migration_004_add_print_columns),
(5, 'create_router_table', migration_005_create_router_table),
(6, 'add_deleted_status', migration_006_add_deleted_status),
]

View File

@@ -619,6 +619,16 @@ def register_routes(bp):
LIMIT 1
''', [process_id], one=True)
def strip_title_prefix(label):
"""Strip [title] prefix from field label for display"""
if label and label.startswith('[title]'):
return label[7:]
return label
def is_title_field(label):
"""Check if field label has [title] prefix"""
return label and label.startswith('[title]')
@bp.route('/')
@login_required
@@ -637,8 +647,35 @@ def register_routes(bp):
flash('You do not have access to this module', 'danger')
return redirect(url_for('home'))
# Get user's active sessions with process info and scan counts
active_sessions = query_db('''
# Check if user wants to see archived/deleted sessions
show_archived = request.args.get('show_archived') == '1'
# Get sessions based on filter
if show_archived:
# Show active + archived + last 20 deleted
all_sessions = query_db('''
SELECT cs.*, cp.process_name, cp.process_key
FROM cons_sessions cs
JOIN cons_processes cp ON cs.process_id = cp.id
WHERE cs.created_by = ?
ORDER BY
CASE cs.status
WHEN 'active' THEN 1
WHEN 'archived' THEN 2
WHEN 'deleted' THEN 3
END,
cs.created_at DESC
''', [user_id])
# Separate active, archived, and deleted (limit deleted to 20)
active_sessions = [s for s in all_sessions if s['status'] == 'active']
archived_sessions = [s for s in all_sessions if s['status'] == 'archived']
deleted_sessions = [s for s in all_sessions if s['status'] == 'deleted'][:20]
combined_sessions = active_sessions + archived_sessions + deleted_sessions
else:
# Show only active sessions
combined_sessions = query_db('''
SELECT cs.*, cp.process_name, cp.process_key
FROM cons_sessions cs
JOIN cons_processes cp ON cs.process_id = cp.id
@@ -646,20 +683,50 @@ def register_routes(bp):
ORDER BY cs.created_at DESC
''', [user_id])
# Get scan counts for each session from their dynamic tables
# Get scan counts and header values for each session
sessions_with_counts = []
for sess in active_sessions:
for sess in combined_sessions:
sess_dict = dict(sess)
# Get scan count
table_name = get_detail_table_name(sess['process_key'])
try:
count_result = query_db(f'''
SELECT COUNT(*) as scan_count FROM {table_name}
WHERE session_id = ? AND is_deleted = 0
''', [sess['id']], one=True)
sess_dict = dict(sess)
sess_dict['scan_count'] = count_result['scan_count'] if count_result else 0
except:
sess_dict = dict(sess)
sess_dict['scan_count'] = 0
# Get header fields with their values
header_fields = query_db('''
SELECT cpf.field_label, cshv.field_value
FROM cons_process_fields cpf
LEFT JOIN cons_session_header_values cshv
ON cpf.id = cshv.field_id AND cshv.session_id = ?
WHERE cpf.process_id = ?
AND cpf.table_type = 'header'
AND cpf.is_active = 1
ORDER BY cpf.sort_order, cpf.id
''', [sess['id'], sess['process_id']])
# Build title from [title] fields, strip prefix for display
title_parts = []
preview_fields = []
for hf in header_fields:
hf_dict = dict(hf)
if is_title_field(hf_dict['field_label']):
clean_label = strip_title_prefix(hf_dict['field_label'])
hf_dict['field_label'] = clean_label
if hf_dict['field_value']:
title_parts.append(f"{clean_label} {hf_dict['field_value']}")
preview_fields.append(hf_dict)
else:
preview_fields.append(hf_dict)
sess_dict['header_preview'] = preview_fields[:5]
sess_dict['session_title'] = f"{sess['process_name']}{', '.join(title_parts)}" if title_parts else sess['process_name']
sessions_with_counts.append(sess_dict)
# Get available process types for creating new sessions
@@ -669,7 +736,8 @@ def register_routes(bp):
return render_template('conssheets/staff_index.html',
sessions=sessions_with_counts,
processes=processes)
processes=processes,
show_archived=show_archived)
@bp.route('/new/<int:process_id>', methods=['GET', 'POST'])
@@ -682,13 +750,19 @@ def register_routes(bp):
flash('Process not found', 'danger')
return redirect(url_for('conssheets.index'))
# Get header fields for this process
header_fields = query_db('''
# Get header fields for this process, stripping [title] prefix
header_fields_raw = query_db('''
SELECT * FROM cons_process_fields
WHERE process_id = ? AND table_type = 'header' AND is_active = 1
ORDER BY sort_order, id
''', [process_id])
header_fields = []
for hf in header_fields_raw:
hf_dict = dict(hf)
hf_dict['field_label'] = strip_title_prefix(hf_dict['field_label'])
header_fields.append(hf_dict)
if request.method == 'POST':
# Validate required fields
missing_required = []
@@ -728,6 +802,97 @@ def register_routes(bp):
header_fields=header_fields,
form_data={})
@bp.route('/session/<int:session_id>/edit-header', methods=['GET', 'POST'])
@login_required
def edit_session_header(session_id):
"""Edit header fields for an existing session"""
sess = query_db('''
SELECT cs.*, cp.process_name, cp.process_key, cp.id as process_id
FROM cons_sessions cs
JOIN cons_processes cp ON cs.process_id = cp.id
WHERE cs.id = ?
''', [session_id], one=True)
if not sess:
flash('Session not found', 'danger')
return redirect(url_for('conssheets.index'))
# Check permission
if sess['created_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']:
flash('Permission denied', 'danger')
return redirect(url_for('conssheets.index'))
# Get header fields for this process, stripping [title] prefix
header_fields_raw = query_db('''
SELECT * FROM cons_process_fields
WHERE process_id = ? AND table_type = 'header' AND is_active = 1
ORDER BY sort_order, id
''', [sess['process_id']])
header_fields = []
for hf in header_fields_raw:
hf_dict = dict(hf)
hf_dict['field_label'] = strip_title_prefix(hf_dict['field_label'])
header_fields.append(hf_dict)
# Get existing values
existing_values = query_db('''
SELECT cpf.field_name, cshv.field_value
FROM cons_process_fields cpf
LEFT JOIN cons_session_header_values cshv
ON cpf.id = cshv.field_id AND cshv.session_id = ?
WHERE cpf.process_id = ? AND cpf.table_type = 'header' AND cpf.is_active = 1
''', [session_id, sess['process_id']])
# Build form_data dict from existing values
form_data = {row['field_name']: row['field_value'] for row in existing_values if row['field_value']}
if request.method == 'POST':
# Validate required fields
missing_required = []
for field in header_fields:
if field['is_required']:
value = request.form.get(field['field_name'], '').strip()
if not value:
missing_required.append(field['field_label'])
if missing_required:
flash(f'Required fields missing: {", ".join(missing_required)}', 'danger')
return render_template('conssheets/new_session.html',
process=sess,
header_fields=header_fields,
form_data=request.form,
edit_mode=True,
session_id=session_id)
# Update header field values
for field in header_fields:
value = request.form.get(field['field_name'], '').strip()
existing = query_db(
'SELECT id FROM cons_session_header_values WHERE session_id = ? AND field_id = ?',
[session_id, field['id']], one=True
)
if existing:
execute_db(
'UPDATE cons_session_header_values SET field_value = ? WHERE session_id = ? AND field_id = ?',
[value, session_id, field['id']]
)
else:
if value:
execute_db(
'INSERT INTO cons_session_header_values (session_id, field_id, field_value) VALUES (?, ?, ?)',
[session_id, field['id'], value]
)
flash('Header updated successfully!', 'success')
return redirect(url_for('conssheets.scan_session', session_id=session_id))
return render_template('conssheets/new_session.html',
process=sess,
header_fields=header_fields,
form_data=form_data,
edit_mode=True,
session_id=session_id)
@bp.route('/session/<int:session_id>')
@login_required
@@ -749,14 +914,24 @@ def register_routes(bp):
flash('This session has been archived', 'warning')
return redirect(url_for('conssheets.index'))
# Get header values for display
header_values = query_db('''
SELECT cpf.field_label, cpf.field_name, cshv.field_value
FROM cons_session_header_values cshv
JOIN cons_process_fields cpf ON cshv.field_id = cpf.id
WHERE cshv.session_id = ?
# Get header values for display, stripping [title] prefix
header_values_raw = query_db('''
SELECT cpf.id as field_id, cpf.field_label, cpf.field_name,
cpf.field_type, cpf.is_required, cpf.max_length,
cshv.field_value
FROM cons_process_fields cpf
LEFT JOIN cons_session_header_values cshv
ON cpf.id = cshv.field_id AND cshv.session_id = ?
WHERE cpf.process_id = ? AND cpf.table_type = 'header' AND cpf.is_active = 1
ORDER BY cpf.sort_order, cpf.id
''', [session_id])
''', [session_id, sess['process_id']])
# Strip [title] prefix for display
header_values = []
for hv in header_values_raw:
hv_dict = dict(hv)
hv_dict['field_label'] = strip_title_prefix(hv_dict['field_label'])
header_values.append(hv_dict)
# Get detail fields for this process (convert to dicts for JSON serialization)
detail_fields_rows = query_db('''
@@ -788,6 +963,41 @@ def register_routes(bp):
dup_key_field=dup_key_field)
@bp.route('/session/<int:session_id>/update-header', methods=['POST'])
@login_required
def update_session_header(session_id):
"""Update header field values for a session"""
sess = query_db('SELECT * FROM cons_sessions WHERE id = ?', [session_id], one=True)
if not sess:
return jsonify({'success': False, 'message': 'Session not found'})
# Check permission
if sess['created_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Permission denied'})
data = request.get_json()
field_values = data.get('field_values', {})
for field_id, field_value in field_values.items():
# Check if value already exists
existing = query_db(
'SELECT id FROM cons_session_header_values WHERE session_id = ? AND field_id = ?',
[session_id, field_id], one=True
)
if existing:
execute_db(
'UPDATE cons_session_header_values SET field_value = ? WHERE session_id = ? AND field_id = ?',
[field_value, session_id, field_id]
)
else:
execute_db(
'INSERT INTO cons_session_header_values (session_id, field_id, field_value) VALUES (?, ?, ?)',
[session_id, field_id, field_value]
)
return jsonify({'success': True})
@bp.route('/session/<int:session_id>/scan', methods=['POST'])
@login_required
def scan_lot(session_id):
@@ -982,6 +1192,60 @@ def register_routes(bp):
return jsonify({'success': True})
@bp.route('/session/<int:session_id>/unarchive', methods=['POST'])
@login_required
def unarchive_session(session_id):
"""Unarchive a session back to active"""
sess = query_db('SELECT * FROM cons_sessions WHERE id = ?', [session_id], one=True)
if not sess:
return jsonify({'success': False, 'message': 'Session not found'})
# Check permission
if sess['created_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Permission denied'})
execute_db('UPDATE cons_sessions SET status = "active" WHERE id = ?', [session_id])
return jsonify({'success': True})
@bp.route('/session/<int:session_id>/delete', methods=['POST'])
@role_required('owner', 'admin')
def delete_session(session_id):
"""Delete a session (admin only) - soft delete session and all detail rows"""
sess = query_db('SELECT cs.*, cp.process_key FROM cons_sessions cs JOIN cons_processes cp ON cs.process_id = cp.id WHERE cs.id = ?', [session_id], one=True)
if not sess:
return jsonify({'success': False, 'message': 'Session not found'})
# Update session status
execute_db('UPDATE cons_sessions SET status = "deleted" WHERE id = ?', [session_id])
# Mark all detail rows as deleted
table_name = get_detail_table_name(sess['process_key'])
execute_db(f'UPDATE {table_name} SET is_deleted = 1 WHERE session_id = ?', [session_id])
return jsonify({'success': True})
@bp.route('/session/<int:session_id>/restore', methods=['POST'])
@role_required('owner', 'admin')
def restore_session(session_id):
"""Restore a deleted session (admin only) - restore session and all detail rows"""
sess = query_db('SELECT cs.*, cp.process_key FROM cons_sessions cs JOIN cons_processes cp ON cs.process_id = cp.id WHERE cs.id = ?', [session_id], one=True)
if not sess:
return jsonify({'success': False, 'message': 'Session not found'})
# Update session status to active
execute_db('UPDATE cons_sessions SET status = "active" WHERE id = ?', [session_id])
# Restore all detail rows
table_name = get_detail_table_name(sess['process_key'])
execute_db(f'UPDATE {table_name} SET is_deleted = 0 WHERE session_id = ?', [session_id])
return jsonify({'success': True})
@bp.route('/session/<int:session_id>/template')
@login_required

View File

@@ -11,10 +11,10 @@
</div>
<div class="form-container" style="max-width: 600px; margin: 0 auto;">
<h1 class="page-title" style="text-align: center;">New {{ process.process_name }} Session</h1>
<p class="page-subtitle" style="text-align: center; margin-bottom: var(--space-xl);">Enter header information to begin scanning</p>
<h1 class="page-title" style="text-align: center;">{% if edit_mode %}Edit{% else %}New{% endif %} {{ process.process_name }} Session</h1>
<p class="page-subtitle" style="text-align: center; margin-bottom: var(--space-xl);">{% if edit_mode %}Update header information{% else %}Enter header information to begin scanning{% endif %}</p>
<form method="POST" class="form-card">
<form method="POST" action="{% if edit_mode %}{{ url_for('conssheets.edit_session_header', session_id=session_id) }}{% endif %}" class="form-card">
{% for field in header_fields %}
<div class="form-group">
<label for="{{ field.field_name }}" class="form-label">
@@ -78,7 +78,7 @@
<div class="form-actions">
<a href="{{ url_for('conssheets.index') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary" {% if not header_fields %}disabled{% endif %}>
Start Scanning
{% if edit_mode %}Save Changes{% else %}Start Scanning{% endif %}
</button>
</div>
</form>

View File

@@ -6,7 +6,13 @@
<div class="count-location-container">
<div class="location-header">
<div class="location-info">
<div style="display: flex; justify-content: space-between; align-items: center;">
<a href="{{ url_for('conssheets.index') }}" class="breadcrumb">← Back to Sessions</a>
<a href="{{ url_for('conssheets.edit_session_header', session_id=session.id) }}"
class="btn-edit-header" title="Edit header fields">
<i class="fa-solid fa-pencil"></i>
</a>
</div>
<div class="location-label">{{ session.process_name }}</div>
<div class="header-values">
{% for hv in header_values %}
@@ -27,7 +33,7 @@
{% endif %}
<div class="scan-card" style="border: 2px solid var(--color-primary); margin-bottom: 20px;">
<div class="scan-header" style="background: rgba(0, 123, 255, 0.1);">
<div class="scan-header" >
<h2 class="scan-title" style="color: var(--color-primary);">🚀 Smart Router Scan</h2>
</div>
<div class="scan-form">
@@ -109,15 +115,21 @@
<i class="fa-solid fa-file-import"></i> Bulk Import Excel
</button>
</div>
<div class="scans-grid scan-header-row" style="--field-count: {{ detail_fields|length }};">
{% for field in detail_fields %}
<div class="cs-scan-row-cell scan-col-header">{{ field.field_label }}</div>
{% endfor %}
<div class="cs-scan-row-cell scan-col-header">Status</div>
</div>
<div id="scansList" class="scans-grid" style="--field-count: {{ detail_fields|length }};">
{% for scan in scans %}
<div class="scan-row scan-row-{{ scan.duplicate_status }}"
<div class="cs-scan-row cs-scan-row-{{ scan.duplicate_status }}"
data-detail-id="{{ scan.id }}"
onclick="openScanDetail(this.dataset.detailId)">
{% for field in detail_fields %}
<div class="scan-row-cell">{% if field.field_type == 'REAL' %}{{ '%.1f'|format(scan[field.field_name]|float) if scan[field.field_name] else '-' }}{% else %}{{ scan[field.field_name] or '-' }}{% endif %}</div>
<div class="cs-scan-row-cell">{% if field.field_type == 'REAL' %}{{ '%.1f'|format(scan[field.field_name]|float) if scan[field.field_name] else '-' }}{% else %}{{ scan[field.field_name] or '-' }}{% endif %}</div>
{% endfor %}
<div class="scan-row-status">
<div class="cs-scan-row-status">
{% if scan.duplicate_status == 'dup_same_session' %}<span class="status-dot status-dot-blue"></span> Dup
{% elif scan.duplicate_status == 'dup_other_session' %}<span class="status-dot status-dot-orange"></span> Warn
{% else %}<span class="status-dot status-dot-green"></span> OK{% endif %}
@@ -178,21 +190,7 @@
</div>
</div>
<style>
.header-values { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin: var(--space-sm) 0; }
.header-pill { background: var(--color-surface-elevated); padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--color-text-muted); }
.header-pill strong { color: var(--color-text); }
.scan-row { display: grid; grid-template-columns: repeat(var(--field-count), 1fr) auto; gap: var(--space-sm); padding: var(--space-md); background: var(--color-surface); border: 2px solid var(--color-border); border-radius: var(--radius-md); margin-bottom: var(--space-sm); cursor: pointer; transition: var(--transition); }
.scan-row:hover { border-color: var(--color-primary); }
.scan-row-cell { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scan-row-dup_same_session { border-left: 4px solid var(--color-duplicate) !important; background: rgba(0, 163, 255, 0.1) !important; }
.scan-row-dup_other_session { border-left: 4px solid var(--color-warning) !important; background: rgba(255, 170, 0, 0.1) !important; }
.scan-row-normal { border-left: 4px solid var(--color-success); }
.modal-duplicate { text-align: center; padding: var(--space-xl); }
.duplicate-lot-number { font-family: var(--font-mono); font-size: 1.5rem; font-weight: 700; color: var(--color-primary); margin-bottom: var(--space-md); }
.duplicate-title { font-size: 1.25rem; margin-bottom: var(--space-sm); }
.duplicate-message { color: var(--color-text-muted); margin-bottom: var(--space-lg); }
</style>
<!-- Styles moved to static/css/style.css -->
<script id="session-data" type="application/json">
{
@@ -247,6 +245,7 @@ function processSmartScan(barcode, confirm = false) {
// --- 1. HANDLE DUPLICATE CONFIRMATION ---
if (data.needs_confirmation) {
playErrorBeep();
isSmartScan = true;
currentDupKeyValue = barcode;
if (data.duplicate_status === 'dup_same_session') {
@@ -296,23 +295,26 @@ function processSmartScan(barcode, confirm = false) {
}
// --- 3. STANDARD SUCCESS/FAIL ---
if (data.success) {
if (data.detail_id && data.data) {
addScanToList(data.detail_id, data.data, data.data.duplicate_status);
}
feedbackArea.style.display = 'none';
if(smartInput) {
smartInput.value = '';
smartInput.focus();
}
feedbackArea.style.display = 'block';
if (data.success) {
feedbackArea.style.background = 'rgba(40, 167, 69, 0.2)';
feedbackArea.style.border = '1px solid #28a745';
feedbackText.style.color = '#28a745';
feedbackText.textContent = data.message;
if (data.message.includes('Saved')) setTimeout(() => location.reload(), 800);
} else {
playErrorBeep();
feedbackArea.style.display = 'block';
feedbackArea.style.background = 'rgba(220, 53, 69, 0.2)';
feedbackArea.style.border = '1px solid #dc3545';
feedbackText.style.color = '#dc3545';
feedbackText.textContent = data.message;
if(smartInput) {
smartInput.value = '';
smartInput.focus();
}
}
})
.catch(err => {
@@ -320,7 +322,7 @@ function processSmartScan(barcode, confirm = false) {
console.error(err); // Log it, don't popup alert to annoy user
});
}
// Function to handle the "Save" click from the Details Modal
// Function to handle the "Save" click from the Details Modal
function saveSmartScanData() {
// 1. Validate we have the original barcode
@@ -357,8 +359,12 @@ function saveSmartScanData() {
.then(data => {
if (data.success) {
document.getElementById('fieldsModal').style.display = 'none';
// Optional: Small delay to let the user see it worked
setTimeout(() => location.reload(), 300);
if (data.detail_id && data.data) {
addScanToList(data.detail_id, data.data, data.data.duplicate_status);
}
currentDupKeyValue = '';
smartInput.value = '';
smartInput.focus();
} else if (data.needs_input) {
alert("Error: Please fill in all required fields.");
} else {
@@ -372,6 +378,25 @@ function resetSmartScan() {
document.getElementById('smartScanner').focus();
document.getElementById('routerFeedback').style.display = 'none';
}
// --- ERROR BEEP ---
function playErrorBeep() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.type = 'square';
oscillator.frequency.setValueAtTime(520, ctx.currentTime);
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.4);
} catch(e) {
console.warn('Audio not available:', e);
}
}
// Standard variables
let currentDupKeyValue = '';
let currentDuplicateStatus = '';
@@ -499,8 +524,8 @@ function submitScan() {
data.updated_entry_ids.forEach(id => {
const row = document.querySelector(`[data-detail-id="${id}"]`);
if (row) {
row.className = 'scan-row scan-row-dup_same_session';
row.querySelector('.scan-row-status').innerHTML = '<span class="status-dot status-dot-blue"></span> Dup';
row.className = 'cs-scan-row cs-scan-row-dup_same_session';
row.querySelector('.cs-scan-row-status').innerHTML = '<span class="status-dot status-dot-blue"></span> Dup';
}
});
}
@@ -525,16 +550,16 @@ function addScanToList(detailId, fieldValues, duplicateStatus) {
if (duplicateStatus === 'dup_same_session') { statusDot = 'blue'; statusText = 'Dup'; }
else if (duplicateStatus === 'dup_other_session') { statusDot = 'orange'; statusText = 'Warn'; }
const scanRow = document.createElement('div');
scanRow.className = 'scan-row scan-row-' + statusClass;
scanRow.className = 'cs-scan-row cs-scan-row-' + statusClass;
scanRow.setAttribute('data-detail-id', detailId);
scanRow.onclick = function() { openScanDetail(detailId); };
let cellsHtml = '';
detailFields.forEach(field => {
let value = fieldValues[field.field_name] || '-';
if (field.field_type === 'REAL' && value !== '-') value = parseFloat(value).toFixed(1);
cellsHtml += `<div class="scan-row-cell">${value}</div>`;
cellsHtml += `<div class="cs-scan-row-cell">${value}</div>`;
});
cellsHtml += `<div class="scan-row-status"><span class="status-dot status-dot-${statusDot}"></span> ${statusText}</div>`;
cellsHtml += `<div class="cs-scan-row-status"><span class="status-dot status-dot-${statusDot}"></span> ${statusText}</div>`;
scanRow.innerHTML = cellsHtml;
scansList.insertBefore(scanRow, scansList.firstChild);
const countSpan = document.getElementById('scanListCount');

View File

@@ -30,26 +30,70 @@
<!-- Active Sessions -->
{% if sessions %}
<div class="section-card">
<h2 class="section-title">📋 My Active Sessions</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md);">
<h2 class="section-title">📋 My Sessions</h2>
<label class="checkbox-toggle">
<input type="checkbox" id="showArchivedToggle" {% if show_archived %}checked{% endif %} onchange="toggleArchived(this.checked)">
<span>Show archived/deleted</span>
</label>
</div>
<div class="sessions-list">
{% for s in sessions %}
<div class="session-list-item-container">
<div class="session-list-item-container session-status-{{ s.status }}">
<a href="{{ url_for('conssheets.scan_session', session_id=s.id) }}" class="session-list-item">
<div class="session-list-info">
<h3 class="session-list-name">{{ s.process_name }}</h3>
<h3 class="session-list-name">{{ s.session_title }}</h3>
<div class="session-list-meta">
<span>Started: {{ s.created_at[:16] }}</span>
<span></span>
<span>{{ s.scan_count or 0 }} lots scanned</span>
<span>{{ s.scan_count or 0 }} Scanned</span>
</div>
{% if s.header_preview %}
<div class="session-list-meta" style="margin-top: var(--space-xs); font-size: 0.8rem;">
{% for hf in s.header_preview %}
<span><strong>{{ hf.field_label }}:</strong> {{ hf.field_value or '—' }}</span>
{% if not loop.last %}<span></span>{% endif %}
{% endfor %}
</div>
{% endif %}
{% if s.status == 'archived' %}
<div class="session-list-meta" style="margin-top: var(--space-xs);">
<span style="color: var(--color-warning);">📦 Archived</span>
</div>
{% elif s.status == 'deleted' %}
<div class="session-list-meta" style="margin-top: var(--space-xs);">
<span style="color: var(--color-danger);">🗑️ Deleted</span>
</div>
{% endif %}
</div>
<div class="session-list-action">
<span class="arrow-icon"></span>
</div>
</a>
<button class="btn-archive" onclick="archiveSession({{ s.id }}, '{{ s.process_name }}')" title="Archive this session">
🗑️
{% if s.status == 'active' %}
<button class="btn-session-action btn-archive" onclick="archiveSession({{ s.id }}, '{{ s.process_name }}')" title="Archive this session">
📦
</button>
{% if session.role in ['owner', 'admin'] %}
<button class="btn-session-action btn-delete" onclick="deleteSession({{ s.id }}, '{{ s.process_name }}')" title="Delete this session">
</button>
{% endif %}
{% elif s.status == 'archived' %}
<button class="btn-session-action btn-unarchive" onclick="unarchiveSession({{ s.id }}, '{{ s.process_name }}')" title="Restore to active">
↩️
</button>
{% if session.role in ['owner', 'admin'] %}
<button class="btn-session-action btn-delete" onclick="deleteSession({{ s.id }}, '{{ s.process_name }}')" title="Delete this session">
</button>
{% endif %}
{% elif s.status == 'deleted' and session.role in ['owner', 'admin'] %}
<button class="btn-session-action btn-restore" onclick="restoreSession({{ s.id }}, '{{ s.process_name }}')" title="Restore from deleted">
🔄
</button>
{% endif %}
</div>
{% endfor %}
</div>
@@ -63,89 +107,20 @@
{% endif %}
</div>
<style>
.new-session-section {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
margin-bottom: var(--space-xl);
}
.process-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
margin-top: var(--space-md);
}
.section-card {
margin-top: var(--space-xl);
}
.session-list-item-container {
display: flex;
align-items: stretch;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.session-list-item {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-lg);
text-decoration: none;
transition: var(--transition);
}
.session-list-item:hover {
border-color: var(--color-primary);
transform: translateX(4px);
}
.session-list-name {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
margin: 0 0 var(--space-xs) 0;
}
.session-list-meta {
display: flex;
gap: var(--space-sm);
font-size: 0.875rem;
color: var(--color-text-muted);
}
.arrow-icon {
font-size: 1.5rem;
color: var(--color-primary);
}
.btn-archive {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-md);
cursor: pointer;
font-size: 1.25rem;
transition: var(--transition);
}
.btn-archive:hover {
border-color: var(--color-danger);
background: rgba(255, 51, 102, 0.1);
}
</style>
<!-- Styles moved to static/css/style.css for maintainability -->
<script>
function toggleArchived(checked) {
const url = new URL(window.location.href);
if (checked) {
url.searchParams.set('show_archived', '1');
} else {
url.searchParams.delete('show_archived');
}
window.location.href = url.toString();
}
function archiveSession(sessionId, processName) {
if (!confirm(`Archive this ${processName} session?\n\nYou can still view it later from the admin panel.`)) {
if (!confirm(`Archive this ${processName} session?\n\nYou can restore it later.`)) {
return;
}
@@ -162,5 +137,62 @@ function archiveSession(sessionId, processName) {
}
});
}
function unarchiveSession(sessionId, processName) {
if (!confirm(`Restore ${processName} session to active?`)) {
return;
}
fetch(`/conssheets/session/${sessionId}/unarchive`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error restoring session');
}
});
}
function deleteSession(sessionId, processName) {
if (!confirm(`⚠️ DELETE ${processName} session?\n\nThis will mark all scanned data as deleted.\nThis action cannot be undone easily.\n\nAre you absolutely sure?`)) {
return;
}
fetch(`/conssheets/session/${sessionId}/delete`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error deleting session');
}
});
}
function restoreSession(sessionId, processName) {
if (!confirm(`Restore ${processName} from deleted?\n\nThis will reactivate the session and all its data.`)) {
return;
}
fetch(`/conssheets/session/${sessionId}/restore`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error restoring session');
}
});
}
</script>
{% endblock %}

View File

@@ -707,6 +707,138 @@ body {
color: var(--color-primary);
}
/* Session List - Archive/Delete Actions */
.new-session-section {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
margin-bottom: var(--space-xl);
}
.process-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
margin-top: var(--space-md);
}
.section-card {
margin-top: var(--space-xl);
}
.session-list-item-container {
display: flex;
align-items: stretch;
gap: 0; /* Remove gap */
margin-bottom: var(--space-sm);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
overflow: hidden;
}
.session-list-item {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
background: transparent; /* Remove duplicate background */
border: none; /* Remove border since container has it */
border-radius: 0;
padding: var(--space-lg);
text-decoration: none;
transition: var(--transition);
}
.session-list-item:hover {
border-color: var(--color-primary);
transform: translateX(4px);
}
.session-list-info {
flex: 1;
}
.session-list-action {
display: flex;
align-items: center;
}
.session-list-meta {
display: flex;
gap: var(--space-sm);
font-size: 0.875rem;
color: var(--color-text-muted);
}
.checkbox-toggle {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.875rem;
color: var(--color-text-muted);
cursor: pointer;
}
.checkbox-toggle input[type="checkbox"] {
cursor: pointer;
}
.btn-session-action {
background: transparent;
border: none;
border-left: 2px solid var(--color-border);
border-radius: 0;
padding: var(--space-xl);
cursor: pointer;
font-size: 2rem;
transition: var(--transition);
min-width: 80px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-session-action.btn-archive:hover {
border-color: var(--color-warning);
background: rgba(255, 170, 0, 0.1);
}
.btn-session-action.btn-delete:hover {
border-color: var(--color-danger);
background: rgba(255, 51, 102, 0.1);
}
.btn-session-action.btn-unarchive:hover {
border-color: var(--color-primary);
background: rgba(0, 212, 255, 0.1);
}
.btn-session-action.btn-restore:hover {
border-color: var(--color-success);
background: rgba(0, 255, 136, 0.1);
}
.session-status-archived {
opacity: 1;
}
.session-status-archived .session-list-item-container {
border-color: var(--color-warning);
background: rgba(255, 170, 0, 0.08);
}
.session-status-deleted {
opacity: 1;
}
.session-status-deleted .session-list-item-container {
border-color: var(--color-danger);
background: rgba(255, 51, 102, 0.08);
}
/* ==================== FORM CONTAINER ==================== */
.form-container {
@@ -1113,6 +1245,23 @@ body {
font-size: 1.5rem;
}
/* ==================== SCAN SESSION ==================== */
.header-values { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin: var(--space-sm) 0; align-items: center; flex-direction: row; }
.header-pill { background: var(--color-surface-elevated); padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--color-text-muted); }
.header-pill strong { color: var(--color-text); }
.btn-edit-header { background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: var(--space-xs) var(--space-sm); color: var(--color-text-muted); cursor: pointer; font-size: 0.75rem; text-decoration: none; transition: var(--transition); display: inline-flex; align-items: center; }
.btn-edit-header:hover { border-color: var(--color-primary); color: var(--color-primary); }
.cs-scan-row { display: grid; grid-template-columns: repeat(var(--field-count), 1fr) auto; gap: var(--space-sm); padding: var(--space-md); background: var(--color-surface); border: 2px solid var(--color-border); border-radius: var(--radius-md); margin-bottom: var(--space-sm); cursor: pointer; transition: var(--transition); }
.cs-scan-row:hover { border-color: var(--color-primary); }
.cs-scan-row-cell { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cs-scan-row-dup_same_session { border-left: 4px solid var(--color-duplicate) !important; background: rgba(0, 163, 255, 0.1) !important; }
.cs-scan-row-dup_other_session { border-left: 4px solid var(--color-warning) !important; background: rgba(255, 170, 0, 0.1) !important; }
.cs-scan-row-normal { border-left: 4px solid var(--color-success); }
.modal-duplicate { text-align: center; padding: var(--space-xl); }
.duplicate-lot-number { font-family: var(--font-mono); font-size: 1.5rem; font-weight: 700; color: var(--color-primary); margin-bottom: var(--space-md); }
.duplicate-title { font-size: 1.25rem; margin-bottom: var(--space-sm); }
.duplicate-message { color: var(--color-text-muted); margin-bottom: var(--space-lg); }
/* ==================== LOCATION COUNTING ==================== */
.count-location-container {
@@ -1130,7 +1279,7 @@ body {
}
.location-label {
font-size: 0.875rem;
font-size: 1.5rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--color-text-muted);
@@ -1303,9 +1452,25 @@ body {
overflow-y: auto;
}
.scan-header-row {
display: grid;
grid-template-columns: repeat(var(--field-count), 1fr) auto;
gap: var(--space-md);
padding: var(--space-xs) var(--space-md);
max-height: none;
overflow-y: visible;
}
.scan-col-header {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
}
.scan-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1.5fr;
grid-template-columns: repeat(var(--field-count), 1fr) 1fr;
gap: var(--space-md);
padding: var(--space-md);
background: var(--color-bg);