1298 lines
48 KiB
HTML
1298 lines
48 KiB
HTML
{% extends 'base.html' %}
|
|
{% load static %}
|
|
|
|
{% block title %}DICOM Metadata Editor - {{ file.filename }}{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<link href="{% static 'assets/plugins/select2/dist/css/select2.min.css' %}" rel="stylesheet" />
|
|
<style>
|
|
.editor-header {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
border-radius: 0.5rem;
|
|
padding: 2rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.editor-layout {
|
|
display: grid;
|
|
grid-template-columns: 1fr 400px;
|
|
gap: 2rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.editor-section {
|
|
background: white;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 0.5rem;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.section-header {
|
|
background: #f8f9fa;
|
|
border-bottom: 1px solid #dee2e6;
|
|
padding: 1rem 1.5rem;
|
|
font-weight: 600;
|
|
color: #495057;
|
|
display: flex;
|
|
justify-content: between;
|
|
align-items: center;
|
|
}
|
|
|
|
.section-content {
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.metadata-tabs {
|
|
border-bottom: 1px solid #dee2e6;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.nav-tabs .nav-link {
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
color: #6c757d;
|
|
font-weight: 600;
|
|
padding: 1rem 1.5rem;
|
|
}
|
|
|
|
.nav-tabs .nav-link.active {
|
|
color: #007bff;
|
|
border-bottom-color: #007bff;
|
|
background: none;
|
|
}
|
|
|
|
.metadata-group {
|
|
background: #f8f9fa;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 0.375rem;
|
|
padding: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.metadata-group h6 {
|
|
color: #495057;
|
|
font-weight: 600;
|
|
margin-bottom: 1rem;
|
|
padding-bottom: 0.5rem;
|
|
border-bottom: 1px solid #dee2e6;
|
|
}
|
|
|
|
.tag-row {
|
|
display: grid;
|
|
grid-template-columns: 120px 200px 1fr auto;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
padding: 0.75rem 0;
|
|
border-bottom: 1px solid #f1f3f4;
|
|
}
|
|
|
|
.tag-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.tag-id {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 0.875rem;
|
|
color: #6c757d;
|
|
background: #f8f9fa;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
.tag-name {
|
|
font-weight: 600;
|
|
color: #495057;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.tag-value {
|
|
flex: 1;
|
|
}
|
|
|
|
.tag-value input, .tag-value select, .tag-value textarea {
|
|
width: 100%;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 0.25rem;
|
|
padding: 0.5rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.tag-value textarea {
|
|
resize: vertical;
|
|
min-height: 60px;
|
|
}
|
|
|
|
.tag-actions {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.btn-tag {
|
|
padding: 0.25rem 0.5rem;
|
|
border: none;
|
|
border-radius: 0.25rem;
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.btn-edit { background: #fff3e0; color: #f57c00; }
|
|
.btn-save { background: #e8f5e8; color: #2e7d32; }
|
|
.btn-cancel { background: #ffebee; color: #d32f2f; }
|
|
.btn-reset { background: #f3e5f5; color: #7b1fa2; }
|
|
|
|
.preview-panel {
|
|
position: sticky;
|
|
top: 2rem;
|
|
height: fit-content;
|
|
}
|
|
|
|
.image-preview {
|
|
width: 100%;
|
|
max-height: 300px;
|
|
object-fit: contain;
|
|
border-radius: 0.375rem;
|
|
background: #f8f9fa;
|
|
border: 1px solid #dee2e6;
|
|
}
|
|
|
|
.file-info {
|
|
background: #f8f9fa;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 0.375rem;
|
|
padding: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.info-row {
|
|
display: flex;
|
|
justify-content: between;
|
|
align-items: center;
|
|
padding: 0.5rem 0;
|
|
border-bottom: 1px solid #dee2e6;
|
|
}
|
|
|
|
.info-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.info-label {
|
|
font-weight: 600;
|
|
color: #6c757d;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.info-value {
|
|
color: #495057;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.validation-errors {
|
|
background: #f8d7da;
|
|
border: 1px solid #f5c6cb;
|
|
border-radius: 0.375rem;
|
|
padding: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
display: none;
|
|
}
|
|
|
|
.validation-errors.show {
|
|
display: block;
|
|
}
|
|
|
|
.error-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.25rem 0;
|
|
color: #721c24;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.changes-summary {
|
|
background: #d1ecf1;
|
|
border: 1px solid #bee5eb;
|
|
border-radius: 0.375rem;
|
|
padding: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
display: none;
|
|
}
|
|
|
|
.changes-summary.show {
|
|
display: block;
|
|
}
|
|
|
|
.change-item {
|
|
display: flex;
|
|
justify-content: between;
|
|
align-items: center;
|
|
padding: 0.5rem 0;
|
|
border-bottom: 1px solid #bee5eb;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.change-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.change-tag {
|
|
font-weight: 600;
|
|
color: #0c5460;
|
|
}
|
|
|
|
.change-values {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: #6c757d;
|
|
}
|
|
|
|
.old-value {
|
|
text-decoration: line-through;
|
|
color: #dc3545;
|
|
}
|
|
|
|
.new-value {
|
|
color: #28a745;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.search-tags {
|
|
position: relative;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.search-input {
|
|
width: 100%;
|
|
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.search-icon {
|
|
position: absolute;
|
|
left: 0.75rem;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: #6c757d;
|
|
}
|
|
|
|
.tag-filter {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.filter-btn {
|
|
padding: 0.5rem 1rem;
|
|
border: 1px solid #dee2e6;
|
|
background: white;
|
|
border-radius: 0.25rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-size: 0.875rem;
|
|
color: #495057;
|
|
}
|
|
|
|
.filter-btn:hover, .filter-btn.active {
|
|
background: #007bff;
|
|
color: white;
|
|
border-color: #007bff;
|
|
}
|
|
|
|
.modified-indicator {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: #ffc107;
|
|
display: inline-block;
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
.required-field {
|
|
position: relative;
|
|
}
|
|
|
|
.required-field::after {
|
|
content: "*";
|
|
color: #dc3545;
|
|
position: absolute;
|
|
right: -10px;
|
|
top: 0;
|
|
}
|
|
|
|
.auto-save-indicator {
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: #28a745;
|
|
color: white;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 0.25rem;
|
|
font-size: 0.875rem;
|
|
z-index: 1050;
|
|
display: none;
|
|
}
|
|
|
|
@media (max-width: 1200px) {
|
|
.editor-layout {
|
|
grid-template-columns: 1fr;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.preview-panel {
|
|
position: static;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.editor-header {
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.section-content {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.tag-row {
|
|
grid-template-columns: 1fr;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.tag-filter {
|
|
justify-content: center;
|
|
}
|
|
}
|
|
|
|
@media print {
|
|
.preview-panel, .tag-actions, .btn {
|
|
display: none !important;
|
|
}
|
|
|
|
.section-header {
|
|
background: none;
|
|
border-bottom: 2px solid #000;
|
|
color: #000;
|
|
}
|
|
|
|
.editor-header {
|
|
background: none;
|
|
color: #000;
|
|
border: 2px solid #000;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div id="content" class="app-content">
|
|
<!-- Auto-save indicator -->
|
|
<div class="auto-save-indicator" id="auto-save-indicator">
|
|
<i class="fas fa-check me-1"></i>Auto-saved
|
|
</div>
|
|
|
|
<!-- 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 'radiology:dashboard' %}">Radiology</a></li>
|
|
<li class="breadcrumb-item"><a href="{% url 'radiology:dicom_file_list' %}">DICOM Files</a></li>
|
|
<li class="breadcrumb-item active">Metadata Editor</li>
|
|
</ol>
|
|
<h1 class="page-header mb-0">
|
|
<i class="fas fa-edit me-2"></i>DICOM Metadata Editor
|
|
</h1>
|
|
</div>
|
|
<div class="ms-auto">
|
|
<button type="button" class="btn btn-outline-secondary me-2" onclick="resetChanges()">
|
|
<i class="fas fa-undo me-1"></i>Reset
|
|
</button>
|
|
<button type="button" class="btn btn-outline-info me-2" onclick="validateMetadata()">
|
|
<i class="fas fa-check-circle me-1"></i>Validate
|
|
</button>
|
|
<button type="button" class="btn btn-outline-warning me-2" onclick="exportMetadata()">
|
|
<i class="fas fa-download me-1"></i>Export
|
|
</button>
|
|
<button type="button" class="btn btn-success" onclick="saveChanges()">
|
|
<i class="fas fa-save me-1"></i>Save Changes
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Editor Header -->
|
|
<div class="editor-header">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-8">
|
|
<h3 class="mb-2">{{ file.filename }}</h3>
|
|
<p class="mb-2">{{ file.patient_name|default:"Unknown Patient" }} - {{ file.study_description|default:"No description" }}</p>
|
|
<div class="d-flex align-items-center gap-3">
|
|
<span class="badge bg-primary">{{ file.modality|default:"Unknown" }}</span>
|
|
<span class="badge bg-info">{{ file.file_size|filesizeformat }}</span>
|
|
<span class="badge bg-success">{{ file.get_status_display }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4 text-md-end">
|
|
<div class="text-white-50 mb-1">Last Modified</div>
|
|
<div class="h6 mb-2">{{ file.modified_at|date:"M d, Y g:i A" }}</div>
|
|
<div class="text-white-50">by {{ file.modified_by.get_full_name|default:"System" }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Validation Errors -->
|
|
<div class="validation-errors" id="validation-errors">
|
|
<h6 class="text-danger mb-3">
|
|
<i class="fas fa-exclamation-triangle me-2"></i>Validation Errors
|
|
</h6>
|
|
<div id="error-list">
|
|
<!-- Errors will be populated here -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Changes Summary -->
|
|
<div class="changes-summary" id="changes-summary">
|
|
<h6 class="text-info mb-3">
|
|
<i class="fas fa-edit me-2"></i>Pending Changes (<span id="changes-count">0</span>)
|
|
</h6>
|
|
<div id="changes-list">
|
|
<!-- Changes will be populated here -->
|
|
</div>
|
|
</div>
|
|
|
|
<div class="editor-layout">
|
|
<!-- Main Editor -->
|
|
<div class="editor-section">
|
|
<div class="section-header">
|
|
<div>
|
|
<i class="fas fa-tags me-2"></i>DICOM Metadata
|
|
</div>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<span class="badge bg-secondary" id="total-tags">{{ total_tags }} tags</span>
|
|
<span class="badge bg-warning" id="modified-tags">0 modified</span>
|
|
</div>
|
|
</div>
|
|
<div class="section-content">
|
|
<!-- Search and Filter -->
|
|
<div class="search-tags">
|
|
<i class="fas fa-search search-icon"></i>
|
|
<input type="text" class="search-input" id="tag-search"
|
|
placeholder="Search tags by name, ID, or value...">
|
|
</div>
|
|
|
|
<div class="tag-filter">
|
|
<button type="button" class="filter-btn active" data-filter="all">All Tags</button>
|
|
<button type="button" class="filter-btn" data-filter="patient">Patient Info</button>
|
|
<button type="button" class="filter-btn" data-filter="study">Study Info</button>
|
|
<button type="button" class="filter-btn" data-filter="series">Series Info</button>
|
|
<button type="button" class="filter-btn" data-filter="image">Image Info</button>
|
|
<button type="button" class="filter-btn" data-filter="modified">Modified</button>
|
|
<button type="button" class="filter-btn" data-filter="required">Required</button>
|
|
</div>
|
|
|
|
<!-- Metadata Tabs -->
|
|
<ul class="nav nav-tabs metadata-tabs" role="tablist">
|
|
<li class="nav-item">
|
|
<a class="nav-link active" data-bs-toggle="tab" href="#patient-tab">
|
|
<i class="fas fa-user me-1"></i>Patient
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#study-tab">
|
|
<i class="fas fa-folder me-1"></i>Study
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#series-tab">
|
|
<i class="fas fa-layer-group me-1"></i>Series
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#image-tab">
|
|
<i class="fas fa-image me-1"></i>Image
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#technical-tab">
|
|
<i class="fas fa-cog me-1"></i>Technical
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
|
|
<div class="tab-content">
|
|
<!-- Patient Information Tab -->
|
|
<div class="tab-pane fade show active" id="patient-tab">
|
|
<div class="metadata-group">
|
|
<h6>Patient Demographics</h6>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0010,0010)</div>
|
|
<div class="tag-name required-field">Patient Name</div>
|
|
<div class="tag-value">
|
|
<input type="text" id="tag-0010-0010" value="{{ file.patient_name|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.patient_name|default:'' }}">
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0010-0010')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0010,0020)</div>
|
|
<div class="tag-name required-field">Patient ID</div>
|
|
<div class="tag-value">
|
|
<input type="text" id="tag-0010-0020" value="{{ file.patient_id|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.patient_id|default:'' }}">
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0010-0020')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0010,0030)</div>
|
|
<div class="tag-name">Patient Birth Date</div>
|
|
<div class="tag-value">
|
|
<input type="date" id="tag-0010-0030" value="{{ file.patient_birth_date|date:'Y-m-d'|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.patient_birth_date|date:'Y-m-d'|default:'' }}">
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0010-0030')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0010,0040)</div>
|
|
<div class="tag-name">Patient Sex</div>
|
|
<div class="tag-value">
|
|
<select id="tag-0010-0040" onchange="markModified(this)" data-original="{{ file.patient_sex|default:'' }}">
|
|
<option value="">Unknown</option>
|
|
<option value="M" {% if file.patient_sex == 'M' %}selected{% endif %}>Male</option>
|
|
<option value="F" {% if file.patient_sex == 'F' %}selected{% endif %}>Female</option>
|
|
<option value="O" {% if file.patient_sex == 'O' %}selected{% endif %}>Other</option>
|
|
</select>
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0010-0040')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Study Information Tab -->
|
|
<div class="tab-pane fade" id="study-tab">
|
|
<div class="metadata-group">
|
|
<h6>Study Details</h6>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0020,000D)</div>
|
|
<div class="tag-name required-field">Study Instance UID</div>
|
|
<div class="tag-value">
|
|
<input type="text" id="tag-0020-000D" value="{{ file.study_instance_uid|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.study_instance_uid|default:'' }}" readonly>
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-edit" onclick="generateUID('tag-0020-000D')">
|
|
<i class="fas fa-sync"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0008,0020)</div>
|
|
<div class="tag-name">Study Date</div>
|
|
<div class="tag-value">
|
|
<input type="date" id="tag-0008-0020" value="{{ file.study_date|date:'Y-m-d'|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.study_date|date:'Y-m-d'|default:'' }}">
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0008-0020')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0008,0030)</div>
|
|
<div class="tag-name">Study Time</div>
|
|
<div class="tag-value">
|
|
<input type="time" id="tag-0008-0030" value="{{ file.study_time|time:'H:i'|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.study_time|time:'H:i'|default:'' }}">
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0008-0030')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0008,1030)</div>
|
|
<div class="tag-name">Study Description</div>
|
|
<div class="tag-value">
|
|
<textarea id="tag-0008-1030" onchange="markModified(this)"
|
|
data-original="{{ file.study_description|default:'' }}">{{ file.study_description|default:'' }}</textarea>
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0008-1030')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Series Information Tab -->
|
|
<div class="tab-pane fade" id="series-tab">
|
|
<div class="metadata-group">
|
|
<h6>Series Details</h6>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0020,000E)</div>
|
|
<div class="tag-name required-field">Series Instance UID</div>
|
|
<div class="tag-value">
|
|
<input type="text" id="tag-0020-000E" value="{{ file.series_instance_uid|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.series_instance_uid|default:'' }}" readonly>
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-edit" onclick="generateUID('tag-0020-000E')">
|
|
<i class="fas fa-sync"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0020,0011)</div>
|
|
<div class="tag-name">Series Number</div>
|
|
<div class="tag-value">
|
|
<input type="number" id="tag-0020-0011" value="{{ file.series_number|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.series_number|default:'' }}">
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0020-0011')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0008,103E)</div>
|
|
<div class="tag-name">Series Description</div>
|
|
<div class="tag-value">
|
|
<textarea id="tag-0008-103E" onchange="markModified(this)"
|
|
data-original="{{ file.series_description|default:'' }}">{{ file.series_description|default:'' }}</textarea>
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0008-103E')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0008,0060)</div>
|
|
<div class="tag-name required-field">Modality</div>
|
|
<div class="tag-value">
|
|
<select id="tag-0008-0060" onchange="markModified(this)" data-original="{{ file.modality|default:'' }}">
|
|
<option value="">Select Modality</option>
|
|
<option value="CT" {% if file.modality == 'CT' %}selected{% endif %}>CT</option>
|
|
<option value="MRI" {% if file.modality == 'MRI' %}selected{% endif %}>MRI</option>
|
|
<option value="XR" {% if file.modality == 'XR' %}selected{% endif %}>X-Ray</option>
|
|
<option value="US" {% if file.modality == 'US' %}selected{% endif %}>Ultrasound</option>
|
|
<option value="CR" {% if file.modality == 'CR' %}selected{% endif %}>CR</option>
|
|
<option value="DX" {% if file.modality == 'DX' %}selected{% endif %}>DX</option>
|
|
</select>
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0008-0060')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Image Information Tab -->
|
|
<div class="tab-pane fade" id="image-tab">
|
|
<div class="metadata-group">
|
|
<h6>Image Properties</h6>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0020,0013)</div>
|
|
<div class="tag-name">Instance Number</div>
|
|
<div class="tag-value">
|
|
<input type="number" id="tag-0020-0013" value="{{ file.instance_number|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.instance_number|default:'' }}">
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0020-0013')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0020,1041)</div>
|
|
<div class="tag-name">Slice Location</div>
|
|
<div class="tag-value">
|
|
<input type="number" step="0.01" id="tag-0020-1041" value="{{ file.slice_location|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.slice_location|default:'' }}">
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0020-1041')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0028,0010)</div>
|
|
<div class="tag-name">Rows</div>
|
|
<div class="tag-value">
|
|
<input type="number" id="tag-0028-0010" value="{{ file.rows|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.rows|default:'' }}">
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0028-0010')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0028,0011)</div>
|
|
<div class="tag-name">Columns</div>
|
|
<div class="tag-value">
|
|
<input type="number" id="tag-0028-0011" value="{{ file.columns|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.columns|default:'' }}">
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0028-0011')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Technical Information Tab -->
|
|
<div class="tab-pane fade" id="technical-tab">
|
|
<div class="metadata-group">
|
|
<h6>Technical Parameters</h6>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0008,0008)</div>
|
|
<div class="tag-name">Image Type</div>
|
|
<div class="tag-value">
|
|
<input type="text" id="tag-0008-0008" value="{{ file.image_type|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.image_type|default:'' }}">
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0008-0008')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0008,0070)</div>
|
|
<div class="tag-name">Manufacturer</div>
|
|
<div class="tag-value">
|
|
<input type="text" id="tag-0008-0070" value="{{ file.manufacturer|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.manufacturer|default:'' }}">
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0008-0070')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-row">
|
|
<div class="tag-id">(0008,1090)</div>
|
|
<div class="tag-name">Manufacturer Model</div>
|
|
<div class="tag-value">
|
|
<input type="text" id="tag-0008-1090" value="{{ file.manufacturer_model|default:'' }}"
|
|
onchange="markModified(this)" data-original="{{ file.manufacturer_model|default:'' }}">
|
|
</div>
|
|
<div class="tag-actions">
|
|
<button type="button" class="btn-tag btn-reset" onclick="resetTag('tag-0008-1090')">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Preview Panel -->
|
|
<div class="preview-panel">
|
|
<!-- File Information -->
|
|
<div class="editor-section">
|
|
<div class="section-header">
|
|
<i class="fas fa-info-circle me-2"></i>File Information
|
|
</div>
|
|
<div class="section-content">
|
|
<div class="file-info">
|
|
<div class="info-row">
|
|
<div class="info-label">Filename:</div>
|
|
<div class="info-value">{{ file.filename }}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Size:</div>
|
|
<div class="info-value">{{ file.file_size|filesizeformat }}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Upload Date:</div>
|
|
<div class="info-value">{{ file.uploaded_at|date:"M d, Y" }}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Status:</div>
|
|
<div class="info-value">{{ file.get_status_display }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if file.thumbnail %}
|
|
<div class="text-center">
|
|
<img src="{{ file.thumbnail.url }}" alt="DICOM Preview" class="image-preview">
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Actions -->
|
|
<div class="editor-section">
|
|
<div class="section-header">
|
|
<i class="fas fa-bolt me-2"></i>Quick Actions
|
|
</div>
|
|
<div class="section-content">
|
|
<div class="d-grid gap-2">
|
|
<button type="button" class="btn btn-outline-primary" onclick="openViewer()">
|
|
<i class="fas fa-eye me-1"></i>Open in Viewer
|
|
</button>
|
|
<button type="button" class="btn btn-outline-info" onclick="downloadFile()">
|
|
<i class="fas fa-download me-1"></i>Download File
|
|
</button>
|
|
<button type="button" class="btn btn-outline-success" onclick="validateDICOM()">
|
|
<i class="fas fa-check-circle me-1"></i>Validate DICOM
|
|
</button>
|
|
<button type="button" class="btn btn-outline-warning" onclick="anonymizeFile()">
|
|
<i class="fas fa-user-secret me-1"></i>Anonymize
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script src="{% static 'assets/plugins/select2/dist/js/select2.min.js' %}"></script>
|
|
|
|
<script>
|
|
let modifiedTags = new Set();
|
|
let originalValues = {};
|
|
|
|
$(document).ready(function() {
|
|
// Initialize Select2
|
|
$('.tag-value select').select2({
|
|
theme: 'bootstrap-5',
|
|
width: '100%'
|
|
});
|
|
|
|
// Store original values
|
|
$('input, select, textarea').each(function() {
|
|
const id = this.id;
|
|
const value = $(this).val();
|
|
originalValues[id] = value;
|
|
});
|
|
|
|
// Auto-save functionality
|
|
let autoSaveTimer;
|
|
$('input, textarea, select').on('input change', function() {
|
|
scheduleAutoSave();
|
|
});
|
|
|
|
// Tag search functionality
|
|
$('#tag-search').on('input', function() {
|
|
filterTags(this.value);
|
|
});
|
|
|
|
// Filter buttons
|
|
$('.filter-btn').click(function() {
|
|
$('.filter-btn').removeClass('active');
|
|
$(this).addClass('active');
|
|
|
|
const filter = $(this).data('filter');
|
|
applyFilter(filter);
|
|
});
|
|
});
|
|
|
|
function markModified(element) {
|
|
const id = element.id;
|
|
const currentValue = $(element).val();
|
|
const originalValue = originalValues[id];
|
|
|
|
if (currentValue !== originalValue) {
|
|
modifiedTags.add(id);
|
|
$(element).closest('.tag-row').addClass('modified');
|
|
|
|
// Add modified indicator
|
|
if (!$(element).closest('.tag-row').find('.modified-indicator').length) {
|
|
$(element).closest('.tag-name').append('<span class="modified-indicator"></span>');
|
|
}
|
|
} else {
|
|
modifiedTags.delete(id);
|
|
$(element).closest('.tag-row').removeClass('modified');
|
|
$(element).closest('.tag-row').find('.modified-indicator').remove();
|
|
}
|
|
|
|
updateChangesDisplay();
|
|
updateModifiedCount();
|
|
}
|
|
|
|
function resetTag(tagId) {
|
|
const element = document.getElementById(tagId);
|
|
const originalValue = originalValues[tagId];
|
|
|
|
$(element).val(originalValue);
|
|
modifiedTags.delete(tagId);
|
|
$(element).closest('.tag-row').removeClass('modified');
|
|
$(element).closest('.tag-row').find('.modified-indicator').remove();
|
|
|
|
updateChangesDisplay();
|
|
updateModifiedCount();
|
|
}
|
|
|
|
function resetChanges() {
|
|
if (modifiedTags.size === 0) {
|
|
showAlert('No changes to reset', 'info');
|
|
return;
|
|
}
|
|
|
|
if (confirm('Are you sure you want to reset all changes? This action cannot be undone.')) {
|
|
modifiedTags.forEach(tagId => {
|
|
resetTag(tagId);
|
|
});
|
|
|
|
showAlert('All changes have been reset', 'success');
|
|
}
|
|
}
|
|
|
|
function updateModifiedCount() {
|
|
$('#modified-tags').text(`${modifiedTags.size} modified`);
|
|
}
|
|
|
|
function updateChangesDisplay() {
|
|
const changesList = document.getElementById('changes-list');
|
|
const changesCount = document.getElementById('changes-count');
|
|
const changesSection = document.getElementById('changes-summary');
|
|
|
|
if (modifiedTags.size === 0) {
|
|
changesSection.classList.remove('show');
|
|
return;
|
|
}
|
|
|
|
changesSection.classList.add('show');
|
|
changesCount.textContent = modifiedTags.size;
|
|
|
|
let html = '';
|
|
modifiedTags.forEach(tagId => {
|
|
const element = document.getElementById(tagId);
|
|
const tagName = $(element).closest('.tag-row').find('.tag-name').text().trim();
|
|
const oldValue = originalValues[tagId] || 'Empty';
|
|
const newValue = $(element).val() || 'Empty';
|
|
|
|
html += `
|
|
<div class="change-item">
|
|
<div class="change-tag">${tagName}</div>
|
|
<div class="change-values">
|
|
<span class="old-value">${oldValue}</span>
|
|
<i class="fas fa-arrow-right mx-2"></i>
|
|
<span class="new-value">${newValue}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
changesList.innerHTML = html;
|
|
}
|
|
|
|
function scheduleAutoSave() {
|
|
clearTimeout(autoSaveTimer);
|
|
autoSaveTimer = setTimeout(autoSave, 30000); // Auto-save after 30 seconds
|
|
}
|
|
|
|
function autoSave() {
|
|
if (modifiedTags.size === 0) return;
|
|
|
|
const changes = {};
|
|
modifiedTags.forEach(tagId => {
|
|
const element = document.getElementById(tagId);
|
|
changes[tagId] = $(element).val();
|
|
});
|
|
|
|
fetch(`/radiology/dicom/{{ file.id }}/metadata/auto-save/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(changes)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showAutoSaveIndicator();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Auto-save failed:', error);
|
|
});
|
|
}
|
|
|
|
function showAutoSaveIndicator() {
|
|
const indicator = document.getElementById('auto-save-indicator');
|
|
indicator.style.display = 'block';
|
|
setTimeout(() => {
|
|
indicator.style.display = 'none';
|
|
}, 2000);
|
|
}
|
|
|
|
function saveChanges() {
|
|
if (modifiedTags.size === 0) {
|
|
showAlert('No changes to save', 'info');
|
|
return;
|
|
}
|
|
|
|
const changes = {};
|
|
modifiedTags.forEach(tagId => {
|
|
const element = document.getElementById(tagId);
|
|
changes[tagId] = $(element).val();
|
|
});
|
|
|
|
fetch(`/radiology/dicom/{{ file.id }}/metadata/save/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(changes)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showAlert('Metadata saved successfully', 'success');
|
|
|
|
// Update original values
|
|
modifiedTags.forEach(tagId => {
|
|
const element = document.getElementById(tagId);
|
|
originalValues[tagId] = $(element).val();
|
|
});
|
|
|
|
// Clear modifications
|
|
modifiedTags.clear();
|
|
$('.tag-row').removeClass('modified');
|
|
$('.modified-indicator').remove();
|
|
updateChangesDisplay();
|
|
updateModifiedCount();
|
|
} else {
|
|
showAlert('Error saving metadata: ' + (data.error || 'Unknown error'), 'danger');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showAlert('Error saving metadata', 'danger');
|
|
});
|
|
}
|
|
|
|
function validateMetadata() {
|
|
const formData = {};
|
|
$('input, select, textarea').each(function() {
|
|
formData[this.id] = $(this).val();
|
|
});
|
|
|
|
fetch(`/radiology/dicom/{{ file.id }}/metadata/validate/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(formData)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
displayValidationResults(data);
|
|
})
|
|
.catch(error => {
|
|
showAlert('Error validating metadata', 'danger');
|
|
});
|
|
}
|
|
|
|
function displayValidationResults(data) {
|
|
const errorsSection = document.getElementById('validation-errors');
|
|
const errorsList = document.getElementById('error-list');
|
|
|
|
if (data.errors && data.errors.length > 0) {
|
|
errorsSection.classList.add('show');
|
|
|
|
let html = '';
|
|
data.errors.forEach(error => {
|
|
html += `
|
|
<div class="error-item">
|
|
<i class="fas fa-exclamation-circle me-2"></i>
|
|
${error.message}
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
errorsList.innerHTML = html;
|
|
} else {
|
|
errorsSection.classList.remove('show');
|
|
showAlert('Metadata validation passed!', 'success');
|
|
}
|
|
}
|
|
|
|
function exportMetadata() {
|
|
window.open(`/radiology/dicom/{{ file.id }}/metadata/export/`, '_blank');
|
|
}
|
|
|
|
function generateUID(tagId) {
|
|
fetch('/radiology/dicom/generate-uid/', {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
|
}
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
const element = document.getElementById(tagId);
|
|
$(element).val(data.uid);
|
|
markModified(element);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showAlert('Error generating UID', 'danger');
|
|
});
|
|
}
|
|
|
|
function filterTags(searchTerm) {
|
|
const rows = document.querySelectorAll('.tag-row');
|
|
|
|
rows.forEach(row => {
|
|
const tagId = row.querySelector('.tag-id').textContent;
|
|
const tagName = row.querySelector('.tag-name').textContent;
|
|
const tagValue = row.querySelector('.tag-value input, .tag-value select, .tag-value textarea').value;
|
|
|
|
const matches = tagId.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
tagName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
tagValue.toLowerCase().includes(searchTerm.toLowerCase());
|
|
|
|
row.style.display = matches ? 'grid' : 'none';
|
|
});
|
|
}
|
|
|
|
function applyFilter(filter) {
|
|
const rows = document.querySelectorAll('.tag-row');
|
|
|
|
rows.forEach(row => {
|
|
let show = true;
|
|
|
|
switch (filter) {
|
|
case 'modified':
|
|
show = row.classList.contains('modified');
|
|
break;
|
|
case 'required':
|
|
show = row.querySelector('.required-field') !== null;
|
|
break;
|
|
case 'patient':
|
|
show = row.closest('#patient-tab') !== null;
|
|
break;
|
|
case 'study':
|
|
show = row.closest('#study-tab') !== null;
|
|
break;
|
|
case 'series':
|
|
show = row.closest('#series-tab') !== null;
|
|
break;
|
|
case 'image':
|
|
show = row.closest('#image-tab') !== null;
|
|
break;
|
|
case 'technical':
|
|
show = row.closest('#technical-tab') !== null;
|
|
break;
|
|
default:
|
|
show = true;
|
|
}
|
|
|
|
row.style.display = show ? 'grid' : 'none';
|
|
});
|
|
}
|
|
|
|
function openViewer() {
|
|
window.open(`/radiology/dicom/{{ file.id }}/viewer/`, '_blank');
|
|
}
|
|
|
|
function downloadFile() {
|
|
window.open(`/radiology/dicom/{{ file.id }}/download/`, '_blank');
|
|
}
|
|
|
|
function validateDICOM() {
|
|
fetch(`/radiology/dicom/{{ file.id }}/validate/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
|
}
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.valid) {
|
|
showAlert('DICOM file is valid', 'success');
|
|
} else {
|
|
showAlert('DICOM validation failed: ' + data.errors.join(', '), 'danger');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showAlert('Error validating DICOM file', 'danger');
|
|
});
|
|
}
|
|
|
|
function anonymizeFile() {
|
|
if (confirm('Are you sure you want to anonymize this DICOM file? This will remove patient identifying information.')) {
|
|
window.location.href = `/radiology/dicom/{{ file.id }}/anonymize/`;
|
|
}
|
|
}
|
|
|
|
function showAlert(message, type) {
|
|
const alertDiv = document.createElement('div');
|
|
alertDiv.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
|
|
alertDiv.style.cssText = 'top: 20px; right: 20px; z-index: 1060; min-width: 300px;';
|
|
alertDiv.innerHTML = `
|
|
${message}
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
`;
|
|
|
|
document.body.appendChild(alertDiv);
|
|
|
|
setTimeout(() => {
|
|
if (alertDiv.parentNode) {
|
|
alertDiv.remove();
|
|
}
|
|
}, 5000);
|
|
}
|
|
</script>
|
|
{% endblock %}
|
|
|