Marwan Alwali be70e47e22 update
2025-08-30 09:45:26 +03:00

811 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends 'base.html' %}
{% load static %}
{% block title %}QC Trend Analysis{% endblock %}
{% block extra_css %}
<style>
.trend-card {
background: #fff;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.trend-card:hover {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.chart-container {
position: relative;
height: 400px;
margin: 1rem 0;
}
.mini-chart-container {
position: relative;
height: 150px;
margin: 0.5rem 0;
}
.filter-section {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 0.375rem;
padding: 1.5rem;
margin-bottom: 1rem;
}
.stat-card.success {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stat-card.warning {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
.stat-card.danger {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
}
.westgard-rule {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 0.25rem;
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.westgard-rule.violated {
background: #f8d7da;
border-color: #f5c6cb;
}
.trend-indicator {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: bold;
}
.trend-up {
background: #d4edda;
color: #155724;
}
.trend-down {
background: #f8d7da;
color: #721c24;
}
.trend-stable {
background: #d1ecf1;
color: #0c5460;
}
.control-limits {
position: absolute;
right: 10px;
top: 10px;
background: rgba(255, 255, 255, 0.9);
padding: 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
}
@media (max-width: 768px) {
.chart-container {
height: 300px;
}
.mini-chart-container {
height: 120px;
}
.filter-section {
padding: 1rem;
}
}
</style>
{% endblock %}
{% block content %}
<div id="content" class="app-content">
<!-- Page Header -->
<div class="d-flex align-items-center mb-3">
<div>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Dashboard</a></li>
<li class="breadcrumb-item"><a href="{% url 'laboratory:dashboard' %}">Laboratory</a></li>
<li class="breadcrumb-item active">QC Trend Analysis</li>
</ol>
<h1 class="page-header mb-0">
<i class="fas fa-chart-line me-2"></i>QC Trend Analysis
</h1>
</div>
<div class="ms-auto">
<div class="btn-group">
<button type="button" class="btn btn-outline-primary" onclick="exportTrendData()">
<i class="fas fa-download me-1"></i>Export
</button>
<button type="button" class="btn btn-outline-info" onclick="printTrendReport()">
<i class="fas fa-print me-1"></i>Print
</button>
<button type="button" class="btn btn-primary" onclick="refreshTrends()">
<i class="fas fa-sync me-1"></i>Refresh
</button>
</div>
</div>
</div>
<!-- Filter Section -->
<div class="filter-section">
<div class="row">
<div class="col-md-3">
<div class="form-group">
<label class="form-label">Test Type</label>
<select class="form-select" id="test-type-filter">
<option value="">All Test Types</option>
{% for test_type in test_types %}
<option value="{{ test_type.id }}">{{ test_type.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label class="form-label">QC Level</label>
<select class="form-select" id="qc-level-filter">
<option value="">All Levels</option>
<option value="level1">Level 1 (Normal)</option>
<option value="level2">Level 2 (Abnormal Low)</option>
<option value="level3">Level 3 (Abnormal High)</option>
</select>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label class="form-label">Date Range</label>
<select class="form-select" id="date-range-filter">
<option value="7">Last 7 days</option>
<option value="30" selected>Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="365">Last year</option>
<option value="custom">Custom range</option>
</select>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label class="form-label">Instrument</label>
<select class="form-select" id="instrument-filter">
<option value="">All Instruments</option>
{% for instrument in instruments %}
<option value="{{ instrument.id }}">{{ instrument.name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="row mt-3" id="custom-date-range" style="display: none;">
<div class="col-md-6">
<div class="form-group">
<label class="form-label">Start Date</label>
<input type="date" class="form-control" id="start-date">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label class="form-label">End Date</label>
<input type="date" class="form-control" id="end-date">
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<button type="button" class="btn btn-primary" onclick="applyFilters()">
<i class="fas fa-filter me-1"></i>Apply Filters
</button>
<button type="button" class="btn btn-outline-secondary ms-2" onclick="clearFilters()">
<i class="fas fa-times me-1"></i>Clear
</button>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="stat-card success">
<div class="d-flex justify-content-between align-items-center">
<div>
<h3 class="mb-1" id="total-samples">{{ stats.total_samples }}</h3>
<p class="mb-0">Total Samples</p>
</div>
<i class="fas fa-vial fa-2x opacity-75"></i>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-center">
<div>
<h3 class="mb-1" id="pass-rate">{{ stats.pass_rate }}%</h3>
<p class="mb-0">Pass Rate</p>
</div>
<i class="fas fa-check-circle fa-2x opacity-75"></i>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card warning">
<div class="d-flex justify-content-between align-items-center">
<div>
<h3 class="mb-1" id="out-of-range">{{ stats.out_of_range }}</h3>
<p class="mb-0">Out of Range</p>
</div>
<i class="fas fa-exclamation-triangle fa-2x opacity-75"></i>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card danger">
<div class="d-flex justify-content-between align-items-center">
<div>
<h3 class="mb-1" id="westgard-violations">{{ stats.westgard_violations }}</h3>
<p class="mb-0">Westgard Violations</p>
</div>
<i class="fas fa-ban fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Main Trend Chart -->
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-chart-line me-2"></i>QC Trend Chart
</h5>
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="mainTrendChart"></canvas>
<div class="control-limits">
<div><strong>Control Limits:</strong></div>
<div>+3σ: <span id="upper-3s">--</span></div>
<div>+2σ: <span id="upper-2s">--</span></div>
<div>Mean: <span id="mean-value">--</span></div>
<div>-2σ: <span id="lower-2s">--</span></div>
<div>-3σ: <span id="lower-3s">--</span></div>
</div>
</div>
</div>
</div>
<!-- Westgard Rules Analysis -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-rules me-2"></i>Westgard Rules Analysis
</h5>
</div>
<div class="card-body">
<div id="westgard-rules">
<div class="westgard-rule">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>1₃s Rule:</strong> 1 control observation exceeds ±3s
</div>
<span class="badge bg-success" id="rule-1-3s">Pass</span>
</div>
</div>
<div class="westgard-rule">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>2₂s Rule:</strong> 2 consecutive controls exceed ±2s
</div>
<span class="badge bg-success" id="rule-2-2s">Pass</span>
</div>
</div>
<div class="westgard-rule">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>R₄s Rule:</strong> Range of 4 consecutive controls exceeds 4s
</div>
<span class="badge bg-success" id="rule-r-4s">Pass</span>
</div>
</div>
<div class="westgard-rule">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>4₁s Rule:</strong> 4 consecutive controls exceed ±1s
</div>
<span class="badge bg-success" id="rule-4-1s">Pass</span>
</div>
</div>
<div class="westgard-rule">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>10x̄ Rule:</strong> 10 consecutive controls on same side of mean
</div>
<span class="badge bg-success" id="rule-10-mean">Pass</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Test Type Trends -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-flask me-2"></i>Test Type Trends
</h5>
</div>
<div class="card-body">
{% for test_trend in test_trends %}
<div class="trend-card">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">{{ test_trend.test_type.name }}</h6>
<span class="trend-indicator trend-{{ test_trend.trend_direction }}">
{% if test_trend.trend_direction == 'up' %}
<i class="fas fa-arrow-up me-1"></i>{{ test_trend.trend_percentage }}%
{% elif test_trend.trend_direction == 'down' %}
<i class="fas fa-arrow-down me-1"></i>{{ test_trend.trend_percentage }}%
{% else %}
<i class="fas fa-minus me-1"></i>Stable
{% endif %}
</span>
</div>
<div class="mini-chart-container">
<canvas id="trend-chart-{{ test_trend.test_type.id }}"></canvas>
</div>
<div class="row text-center mt-2">
<div class="col-4">
<small class="text-muted">Samples</small>
<div class="fw-bold">{{ test_trend.sample_count }}</div>
</div>
<div class="col-4">
<small class="text-muted">Pass Rate</small>
<div class="fw-bold">{{ test_trend.pass_rate }}%</div>
</div>
<div class="col-4">
<small class="text-muted">CV</small>
<div class="fw-bold">{{ test_trend.cv }}%</div>
</div>
</div>
</div>
{% empty %}
<div class="text-muted text-center py-3">
<i class="fas fa-chart-line fa-2x mb-2"></i>
<p>No trend data available</p>
</div>
{% endfor %}
</div>
</div>
<!-- Recent Violations -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-exclamation-triangle me-2"></i>Recent Violations
</h5>
</div>
<div class="card-body">
{% for violation in recent_violations %}
<div class="d-flex align-items-center mb-3">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-circle text-danger"></i>
</div>
<div class="flex-grow-1 ms-3">
<div class="fw-bold">{{ violation.rule_name }}</div>
<small class="text-muted">
{{ violation.test_type.name }} - {{ violation.sample_id }}
</small>
<div class="text-muted">{{ violation.created_at|date:"M d, Y H:i" }}</div>
</div>
<div class="flex-shrink-0">
<span class="badge bg-danger">{{ violation.severity }}</span>
</div>
</div>
{% empty %}
<div class="text-muted text-center py-3">
<i class="fas fa-check-circle fa-2x mb-2 text-success"></i>
<p>No recent violations</p>
</div>
{% endfor %}
</div>
</div>
<!-- Control Statistics -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-calculator me-2"></i>Control Statistics
</h5>
</div>
<div class="card-body">
<div class="row text-center mb-3">
<div class="col-6">
<h4 class="text-primary" id="mean-stat">{{ control_stats.mean|floatformat:2 }}</h4>
<small class="text-muted">Mean</small>
</div>
<div class="col-6">
<h4 class="text-info" id="sd-stat">{{ control_stats.sd|floatformat:2 }}</h4>
<small class="text-muted">Standard Deviation</small>
</div>
</div>
<div class="row text-center mb-3">
<div class="col-6">
<h5 class="text-success" id="cv-stat">{{ control_stats.cv|floatformat:1 }}%</h5>
<small class="text-muted">CV</small>
</div>
<div class="col-6">
<h5 class="text-warning" id="range-stat">{{ control_stats.range|floatformat:2 }}</h5>
<small class="text-muted">Range</small>
</div>
</div>
<hr>
<div class="mb-2">
<div class="d-flex justify-content-between">
<span>+3σ Limit:</span>
<span class="fw-bold" id="plus-3s">{{ control_stats.plus_3s|floatformat:2 }}</span>
</div>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between">
<span>+2σ Limit:</span>
<span class="fw-bold" id="plus-2s">{{ control_stats.plus_2s|floatformat:2 }}</span>
</div>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between">
<span>-2σ Limit:</span>
<span class="fw-bold" id="minus-2s">{{ control_stats.minus_2s|floatformat:2 }}</span>
</div>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between">
<span>-3σ Limit:</span>
<span class="fw-bold" id="minus-3s">{{ control_stats.minus_3s|floatformat:2 }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'assets/plugins/chart.js/dist/chart.min.js' %}"></script>
<script>
let mainChart = null;
let miniCharts = {};
$(document).ready(function() {
// Initialize main trend chart
initializeMainTrendChart();
// Initialize mini charts for each test type
initializeMiniCharts();
// Filter event handlers
$('#date-range-filter').on('change', function() {
if ($(this).val() === 'custom') {
$('#custom-date-range').show();
} else {
$('#custom-date-range').hide();
}
});
// Auto-apply filters when changed
$('#test-type-filter, #qc-level-filter, #instrument-filter').on('change', function() {
applyFilters();
});
});
function initializeMainTrendChart() {
const ctx = document.getElementById('mainTrendChart').getContext('2d');
const chartData = {
labels: {{ trend_labels|safe|default:"[]" }},
datasets: [{
label: 'QC Results',
data: {{ trend_data|safe|default:"[]" }},
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
tension: 0.1,
pointRadius: 4,
pointHoverRadius: 6
}, {
label: '+3σ',
data: {{ upper_3s_line|safe|default:"[]" }},
borderColor: 'rgb(255, 99, 132)',
borderDash: [5, 5],
fill: false,
pointRadius: 0
}, {
label: '+2σ',
data: {{ upper_2s_line|safe|default:"[]" }},
borderColor: 'rgb(255, 159, 64)',
borderDash: [3, 3],
fill: false,
pointRadius: 0
}, {
label: 'Mean',
data: {{ mean_line|safe|default:"[]" }},
borderColor: 'rgb(54, 162, 235)',
borderDash: [1, 1],
fill: false,
pointRadius: 0
}, {
label: '-2σ',
data: {{ lower_2s_line|safe|default:"[]" }},
borderColor: 'rgb(255, 159, 64)',
borderDash: [3, 3],
fill: false,
pointRadius: 0
}, {
label: '-3σ',
data: {{ lower_3s_line|safe|default:"[]" }},
borderColor: 'rgb(255, 99, 132)',
borderDash: [5, 5],
fill: false,
pointRadius: 0
}]
};
mainChart = new Chart(ctx, {
type: 'line',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Quality Control Trend Analysis'
},
legend: {
position: 'bottom'
}
},
scales: {
y: {
beginAtZero: false,
title: {
display: true,
text: 'Value'
}
},
x: {
title: {
display: true,
text: 'Date'
}
}
},
interaction: {
intersect: false,
mode: 'index'
}
}
});
}
function initializeMiniCharts() {
{% for test_trend in test_trends %}
const ctx{{ test_trend.test_type.id }} = document.getElementById('trend-chart-{{ test_trend.test_type.id }}').getContext('2d');
miniCharts[{{ test_trend.test_type.id }}] = new Chart(ctx{{ test_trend.test_type.id }}, {
type: 'line',
data: {
labels: {{ test_trend.labels|safe|default:"[]" }},
datasets: [{
data: {{ test_trend.data|safe|default:"[]" }},
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
tension: 0.1,
pointRadius: 2,
pointHoverRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
display: false,
beginAtZero: false
},
x: {
display: false
}
},
elements: {
point: {
radius: 2
}
}
}
});
{% endfor %}
}
function applyFilters() {
const filters = {
test_type: $('#test-type-filter').val(),
qc_level: $('#qc-level-filter').val(),
date_range: $('#date-range-filter').val(),
instrument: $('#instrument-filter').val(),
start_date: $('#start-date').val(),
end_date: $('#end-date').val()
};
// Show loading state
showLoadingState();
// Make AJAX request to update trends
$.ajax({
url: '{% url "laboratory:qc_trend_data" %}',
method: 'GET',
data: filters,
success: function(response) {
updateTrendData(response);
updateStatistics(response.stats);
updateWestgardRules(response.westgard_rules);
hideLoadingState();
},
error: function() {
alert('Error loading trend data');
hideLoadingState();
}
});
}
function clearFilters() {
$('#test-type-filter').val('');
$('#qc-level-filter').val('');
$('#date-range-filter').val('30');
$('#instrument-filter').val('');
$('#custom-date-range').hide();
applyFilters();
}
function updateTrendData(data) {
if (mainChart) {
mainChart.data.labels = data.trend_labels;
mainChart.data.datasets[0].data = data.trend_data;
mainChart.data.datasets[1].data = data.upper_3s_line;
mainChart.data.datasets[2].data = data.upper_2s_line;
mainChart.data.datasets[3].data = data.mean_line;
mainChart.data.datasets[4].data = data.lower_2s_line;
mainChart.data.datasets[5].data = data.lower_3s_line;
mainChart.update();
}
// Update control limits display
$('#upper-3s').text(data.control_limits.upper_3s);
$('#upper-2s').text(data.control_limits.upper_2s);
$('#mean-value').text(data.control_limits.mean);
$('#lower-2s').text(data.control_limits.lower_2s);
$('#lower-3s').text(data.control_limits.lower_3s);
}
function updateStatistics(stats) {
$('#total-samples').text(stats.total_samples);
$('#pass-rate').text(stats.pass_rate + '%');
$('#out-of-range').text(stats.out_of_range);
$('#westgard-violations').text(stats.westgard_violations);
$('#mean-stat').text(stats.mean);
$('#sd-stat').text(stats.sd);
$('#cv-stat').text(stats.cv + '%');
$('#range-stat').text(stats.range);
$('#plus-3s').text(stats.plus_3s);
$('#plus-2s').text(stats.plus_2s);
$('#minus-2s').text(stats.minus_2s);
$('#minus-3s').text(stats.minus_3s);
}
function updateWestgardRules(rules) {
Object.keys(rules).forEach(rule => {
const element = $('#rule-' + rule.replace('_', '-'));
if (rules[rule].violated) {
element.removeClass('bg-success').addClass('bg-danger').text('Violated');
element.closest('.westgard-rule').addClass('violated');
} else {
element.removeClass('bg-danger').addClass('bg-success').text('Pass');
element.closest('.westgard-rule').removeClass('violated');
}
});
}
function showLoadingState() {
// Add loading overlay or spinner
$('.chart-container').append('<div class="loading-overlay"><i class="fas fa-spinner fa-spin fa-2x"></i></div>');
}
function hideLoadingState() {
$('.loading-overlay').remove();
}
function refreshTrends() {
applyFilters();
}
function exportTrendData() {
const filters = {
test_type: $('#test-type-filter').val(),
qc_level: $('#qc-level-filter').val(),
date_range: $('#date-range-filter').val(),
instrument: $('#instrument-filter').val(),
start_date: $('#start-date').val(),
end_date: $('#end-date').val(),
format: 'excel'
};
const params = new URLSearchParams(filters);
window.location.href = '{% url "laboratory:export_qc_trends" %}?' + params.toString();
}
function printTrendReport() {
window.print();
}
</script>
<style>
@media print {
.btn-group, .filter-section {
display: none !important;
}
.chart-container {
height: 300px !important;
}
.card {
break-inside: avoid;
}
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
</style>
{% endblock %}