v0.10.0 - Add session archive/activate feature with access controls
This commit is contained in:
46
app.py
46
app.py
@@ -36,7 +36,7 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)
|
|||||||
|
|
||||||
|
|
||||||
# 1. Define the version
|
# 1. Define the version
|
||||||
APP_VERSION = '0.9.0'
|
APP_VERSION = '0.10.0'
|
||||||
|
|
||||||
# 2. Inject it into all templates automatically
|
# 2. Inject it into all templates automatically
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
@@ -94,20 +94,38 @@ def dashboard():
|
|||||||
|
|
||||||
if role in ['owner', 'admin']:
|
if role in ['owner', 'admin']:
|
||||||
# Admin dashboard
|
# Admin dashboard
|
||||||
active_sessions = query_db('''
|
show_archived = request.args.get('show_archived', '0') == '1'
|
||||||
SELECT s.*, u.full_name as created_by_name,
|
|
||||||
COUNT(DISTINCT lc.location_count_id) as total_locations,
|
|
||||||
SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations,
|
|
||||||
SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations
|
|
||||||
FROM CountSessions s
|
|
||||||
LEFT JOIN Users u ON s.created_by = u.user_id
|
|
||||||
LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id
|
|
||||||
WHERE s.status = 'active'
|
|
||||||
GROUP BY s.session_id
|
|
||||||
ORDER BY s.created_timestamp DESC
|
|
||||||
''')
|
|
||||||
|
|
||||||
return render_template('admin_dashboard.html', sessions=active_sessions)
|
if show_archived:
|
||||||
|
# Show all sessions (active and archived)
|
||||||
|
sessions_list = query_db('''
|
||||||
|
SELECT s.*, u.full_name as created_by_name,
|
||||||
|
COUNT(DISTINCT lc.location_count_id) as total_locations,
|
||||||
|
SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations,
|
||||||
|
SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations
|
||||||
|
FROM CountSessions s
|
||||||
|
LEFT JOIN Users u ON s.created_by = u.user_id
|
||||||
|
LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id
|
||||||
|
WHERE s.status IN ('active', 'archived')
|
||||||
|
GROUP BY s.session_id
|
||||||
|
ORDER BY s.status ASC, s.created_timestamp DESC
|
||||||
|
''')
|
||||||
|
else:
|
||||||
|
# Show only active sessions
|
||||||
|
sessions_list = query_db('''
|
||||||
|
SELECT s.*, u.full_name as created_by_name,
|
||||||
|
COUNT(DISTINCT lc.location_count_id) as total_locations,
|
||||||
|
SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations,
|
||||||
|
SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations
|
||||||
|
FROM CountSessions s
|
||||||
|
LEFT JOIN Users u ON s.created_by = u.user_id
|
||||||
|
LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id
|
||||||
|
WHERE s.status = 'active'
|
||||||
|
GROUP BY s.session_id
|
||||||
|
ORDER BY s.created_timestamp DESC
|
||||||
|
''')
|
||||||
|
|
||||||
|
return render_template('admin_dashboard.html', sessions=sessions_list, show_archived=show_archived)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Staff dashboard
|
# Staff dashboard
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -3,6 +3,12 @@ from db import query_db, execute_db, get_db
|
|||||||
from utils import login_required
|
from utils import login_required
|
||||||
|
|
||||||
counting_bp = Blueprint('counting', __name__)
|
counting_bp = Blueprint('counting', __name__)
|
||||||
|
def get_active_session(session_id):
|
||||||
|
"""Get session if it exists and is not archived. Returns None if invalid."""
|
||||||
|
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
|
||||||
|
if not sess or sess['status'] == 'archived':
|
||||||
|
return None
|
||||||
|
return sess
|
||||||
|
|
||||||
@counting_bp.route('/count/<int:session_id>')
|
@counting_bp.route('/count/<int:session_id>')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -29,6 +35,10 @@ def my_counts(session_id):
|
|||||||
flash('Session not found', 'danger')
|
flash('Session not found', 'danger')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
if sess['status'] == 'archived':
|
||||||
|
flash('This session has been archived', 'warning')
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
# Get this user's active bins
|
# Get this user's active bins
|
||||||
active_bins = query_db('''
|
active_bins = query_db('''
|
||||||
SELECT lc.*,
|
SELECT lc.*,
|
||||||
@@ -65,6 +75,11 @@ def my_counts(session_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def start_bin_count(session_id):
|
def start_bin_count(session_id):
|
||||||
"""Start counting a new bin"""
|
"""Start counting a new bin"""
|
||||||
|
sess = get_active_session(session_id)
|
||||||
|
if not sess:
|
||||||
|
flash('Session not found or archived', 'warning')
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
location_name = request.form.get('location_name', '').strip().upper()
|
location_name = request.form.get('location_name', '').strip().upper()
|
||||||
|
|
||||||
if not location_name:
|
if not location_name:
|
||||||
@@ -125,7 +140,10 @@ def complete_location(location_count_id):
|
|||||||
def count_location(session_id, location_count_id):
|
def count_location(session_id, location_count_id):
|
||||||
"""Count lots in a specific location"""
|
"""Count lots in a specific location"""
|
||||||
# Get session info to determine type (Cycle Count vs Physical)
|
# Get session info to determine type (Cycle Count vs Physical)
|
||||||
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
|
sess = get_active_session(session_id)
|
||||||
|
if not sess:
|
||||||
|
flash('Session not found or archived', 'warning')
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
location = query_db('''
|
location = query_db('''
|
||||||
SELECT * FROM LocationCounts
|
SELECT * FROM LocationCounts
|
||||||
@@ -181,6 +199,10 @@ def count_location(session_id, location_count_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def scan_lot(session_id, location_count_id):
|
def scan_lot(session_id, location_count_id):
|
||||||
"""Process a lot scan with duplicate detection"""
|
"""Process a lot scan with duplicate detection"""
|
||||||
|
sess = get_active_session(session_id)
|
||||||
|
if not sess:
|
||||||
|
return jsonify({'success': False, 'message': 'Session not found or archived'})
|
||||||
|
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
lot_number = data.get('lot_number', '').strip()
|
lot_number = data.get('lot_number', '').strip()
|
||||||
weight = data.get('weight')
|
weight = data.get('weight')
|
||||||
@@ -586,6 +608,10 @@ def recalculate_duplicate_status(session_id, lot_number, current_location):
|
|||||||
@login_required
|
@login_required
|
||||||
def finish_location(session_id, location_count_id):
|
def finish_location(session_id, location_count_id):
|
||||||
"""Finish counting a location"""
|
"""Finish counting a location"""
|
||||||
|
sess = get_active_session(session_id)
|
||||||
|
if not sess:
|
||||||
|
return jsonify({'success': False, 'message': 'Session not found or archived'})
|
||||||
|
|
||||||
# Get location info
|
# Get location info
|
||||||
location = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?',
|
location = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?',
|
||||||
[location_count_id], one=True)
|
[location_count_id], one=True)
|
||||||
|
|||||||
@@ -209,3 +209,36 @@ def get_status_details(session_id, status):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error in get_status_details: {str(e)}")
|
print(f"Error in get_status_details: {str(e)}")
|
||||||
return jsonify({'success': False, 'message': f'Error: {str(e)}'})
|
return jsonify({'success': False, 'message': f'Error: {str(e)}'})
|
||||||
|
|
||||||
|
@sessions_bp.route('/session/<int:session_id>/archive', methods=['POST'])
|
||||||
|
@role_required('owner', 'admin')
|
||||||
|
def archive_session(session_id):
|
||||||
|
"""Archive a count session"""
|
||||||
|
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
|
||||||
|
|
||||||
|
if not sess:
|
||||||
|
return jsonify({'success': False, 'message': 'Session not found'})
|
||||||
|
|
||||||
|
if sess['status'] == 'archived':
|
||||||
|
return jsonify({'success': False, 'message': 'Session is already archived'})
|
||||||
|
|
||||||
|
execute_db('UPDATE CountSessions SET status = ? WHERE session_id = ?', ['archived', session_id])
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Session archived successfully'})
|
||||||
|
|
||||||
|
|
||||||
|
@sessions_bp.route('/session/<int:session_id>/activate', methods=['POST'])
|
||||||
|
@role_required('owner', 'admin')
|
||||||
|
def activate_session(session_id):
|
||||||
|
"""Reactivate an archived session"""
|
||||||
|
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
|
||||||
|
|
||||||
|
if not sess:
|
||||||
|
return jsonify({'success': False, 'message': 'Session not found'})
|
||||||
|
|
||||||
|
if sess['status'] != 'archived':
|
||||||
|
return jsonify({'success': False, 'message': 'Session is not archived'})
|
||||||
|
|
||||||
|
execute_db('UPDATE CountSessions SET status = ? WHERE session_id = ?', ['active', session_id])
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Session activated successfully'})
|
||||||
@@ -2119,3 +2119,36 @@ body {
|
|||||||
.scroll-to-bottom {
|
.scroll-to-bottom {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== ARCHIVED SESSIONS ==================== */
|
||||||
|
|
||||||
|
.session-archived {
|
||||||
|
opacity: 0.7;
|
||||||
|
border-color: var(--color-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-archived:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archived-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
background: var(--color-text-dim);
|
||||||
|
color: var(--color-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-left: var(--space-sm);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
/* Session Detail Header Actions */
|
||||||
|
.session-detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-actions-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
@@ -23,18 +23,34 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dashboard-header">
|
<div class="dashboard-header">
|
||||||
<h1 class="page-title">Admin Dashboard</h1>
|
<div class="header-left">
|
||||||
|
<h1 class="page-title">Admin Dashboard</h1>
|
||||||
|
<label class="filter-toggle">
|
||||||
|
<input type="checkbox" id="showArchived" {% if show_archived %}checked{% endif %} onchange="toggleArchived()">
|
||||||
|
<span class="filter-label">Show Archived</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<a href="{{ url_for('sessions.create_session') }}" class="btn btn-primary">
|
<a href="{{ url_for('sessions.create_session') }}" class="btn btn-primary">
|
||||||
<span class="btn-icon">+</span> New Session
|
<span class="btn-icon">+</span> New Session
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleArchived() {
|
||||||
|
const checked = document.getElementById('showArchived').checked;
|
||||||
|
window.location.href = '{{ url_for("dashboard") }}' + (checked ? '?show_archived=1' : '');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
{% if sessions %}
|
{% if sessions %}
|
||||||
<div class="sessions-grid">
|
<div class="sessions-grid">
|
||||||
{% for session in sessions %}
|
{% for session in sessions %}
|
||||||
<div class="session-card">
|
<div class="session-card {% if session.status == 'archived' %}session-archived{% endif %}">
|
||||||
<div class="session-card-header">
|
<div class="session-card-header">
|
||||||
<h3 class="session-name">{{ session.session_name }}</h3>
|
<h3 class="session-name">
|
||||||
|
{{ session.session_name }}
|
||||||
|
{% if session.status == 'archived' %}<span class="archived-badge">ARCHIVED</span>{% endif %}
|
||||||
|
</h3>
|
||||||
<span class="session-type-badge session-type-{{ session.session_type }}">
|
<span class="session-type-badge session-type-{{ session.session_type }}">
|
||||||
{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}
|
{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -6,13 +6,22 @@
|
|||||||
<div class="session-detail-container">
|
<div class="session-detail-container">
|
||||||
<div class="session-detail-header">
|
<div class="session-detail-header">
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</a>
|
<a href="{{ url_for('dashboard') }}{% if count_session.status == 'archived' %}?show_archived=1{% endif %}" class="breadcrumb">← Back to Dashboard</a>
|
||||||
<h1 class="page-title">{{ count_session.session_name }}</h1>
|
<h1 class="page-title">
|
||||||
<!-- Fixed variable name from session.session_type to count_session.session_type -->
|
{{ count_session.session_name }}
|
||||||
|
{% if count_session.status == 'archived' %}<span class="archived-badge">ARCHIVED</span>{% endif %}
|
||||||
|
</h1>
|
||||||
<span class="session-type-badge session-type-{{ count_session.session_type }}">
|
<span class="session-type-badge session-type-{{ count_session.session_type }}">
|
||||||
{{ 'Full Physical' if count_session.session_type == 'full_physical' else 'Cycle Count' }}
|
{{ 'Full Physical' if count_session.session_type == 'full_physical' else 'Cycle Count' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="session-actions-header">
|
||||||
|
{% if count_session.status == 'archived' %}
|
||||||
|
<button class="btn btn-success" onclick="activateSession()">✓ Activate Session</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-secondary" onclick="archiveSession()">📦 Archive Session</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Baseline Upload Section -->
|
<!-- Baseline Upload Section -->
|
||||||
@@ -675,5 +684,38 @@ document.addEventListener('keydown', function(e) {
|
|||||||
closeReopenConfirm();
|
closeReopenConfirm();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
function archiveSession() {
|
||||||
|
if (!confirm('Archive this session? It will be hidden from the main dashboard but can be reactivated later.')) return;
|
||||||
|
|
||||||
|
fetch('{{ url_for("sessions.archive_session", session_id=count_session.session_id) }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'}
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
window.location.href = '{{ url_for("dashboard") }}';
|
||||||
|
} else {
|
||||||
|
alert(data.message || 'Error archiving session');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateSession() {
|
||||||
|
if (!confirm('Reactivate this session? It will appear on the main dashboard again.')) return;
|
||||||
|
|
||||||
|
fetch('{{ url_for("sessions.activate_session", session_id=count_session.session_id) }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'}
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert(data.message || 'Error activating session');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user