From bb17300f3cf227fb5c9f8095e5ebc596825a7416 Mon Sep 17 00:00:00 2001 From: Tom Searle Date: Tue, 20 Jan 2026 12:27:11 +0000 Subject: [PATCH 01/26] feat(medcat-trainer): first pass at project admin initial page not using the django admin... --- medcat-trainer/webapp/api/api/permissions.py | 25 + medcat-trainer/webapp/api/api/views.py | 139 +++- medcat-trainer/webapp/api/core/urls.py | 4 + medcat-trainer/webapp/frontend/src/App.vue | 1 + .../webapp/frontend/src/router/index.ts | 6 + .../webapp/frontend/src/views/Home.vue | 26 +- .../frontend/src/views/ProjectAdmin.vue | 673 ++++++++++++++++++ 7 files changed, 868 insertions(+), 6 deletions(-) create mode 100644 medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue diff --git a/medcat-trainer/webapp/api/api/permissions.py b/medcat-trainer/webapp/api/api/permissions.py index e8995c01f..2a2f04ff3 100644 --- a/medcat-trainer/webapp/api/api/permissions.py +++ b/medcat-trainer/webapp/api/api/permissions.py @@ -1,4 +1,7 @@ from rest_framework import permissions +from rest_framework.exceptions import PermissionDenied +from .models import ProjectAnnotateEntities, ProjectGroup + class IsReadOnly(permissions.BasePermission): """ @@ -9,3 +12,25 @@ def has_permission(self, request, view): # Read permissions are allowed to any request, # so we'll always allow GET, HEAD or OPTIONS requests. return request.method in permissions.SAFE_METHODS + + +def is_project_admin(user, project): + """ + Check if a user is an admin of a project. + A user is a project admin if: + 1. They are a member of the project, OR + 2. They are an administrator of the project's group (if the project has a group) + 3. They are a superuser/staff + """ + if user.is_superuser or user.is_staff: + return True + + # Check if user is a member of the project + if project.members.filter(id=user.id).exists(): + return True + + # Check if user is an administrator of the project's group + if project.group and project.group.administrators.filter(id=user.id).exists(): + return True + + return False diff --git a/medcat-trainer/webapp/api/api/views.py b/medcat-trainer/webapp/api/api/views.py index ea8b75d69..08955334c 100644 --- a/medcat-trainer/webapp/api/api/views.py +++ b/medcat-trainer/webapp/api/api/views.py @@ -15,7 +15,7 @@ from django_filters import rest_framework as drf from rest_framework import viewsets -from rest_framework.decorators import api_view +from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response from medcat.components.ner.trf.deid import DeIdModel from medcat.utils.cdb_utils import ch2pt_from_pt2ch, get_all_ch, snomed_ct_concept_path @@ -1003,3 +1003,140 @@ def project_progress(request): out[p] = {'validated_count': val_docs, 'dataset_count': ds_doc_count} return Response(out) + + +@api_view(http_method_names=['GET']) +@permission_classes([permissions.IsAuthenticated]) +def project_admin_projects(request): + """ + Get all projects where the user is a project admin. + """ + user = request.user + projects = ProjectAnnotateEntities.objects.filter(members=user.id) + + # Also include projects where user is admin of the project's group + group_admin_projects = ProjectAnnotateEntities.objects.filter( + group__administrators=user.id + ) + projects = (projects | group_admin_projects).distinct() + + serializer = ProjectAnnotateEntitiesSerializer(projects, many=True) + return Response(serializer.data) + + +@api_view(http_method_names=['GET', 'PUT', 'DELETE']) +@permission_classes([permissions.IsAuthenticated]) +def project_admin_detail(request, project_id): + """ + Get, update, or delete a project (only if user is project admin). + """ + try: + project = ProjectAnnotateEntities.objects.get(id=project_id) + except ProjectAnnotateEntities.DoesNotExist: + return Response({'error': 'Project not found'}, status=404) + + # Check if user is project admin + from .permissions import is_project_admin + if not is_project_admin(request.user, project): + return Response({'error': 'You do not have permission to access this project'}, status=403) + + if request.method == 'GET': + serializer = ProjectAnnotateEntitiesSerializer(project) + return Response(serializer.data) + + elif request.method == 'PUT': + # Handle both JSON and FormData + data = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data) + + # Convert many-to-many fields from lists to proper format + if 'cdb_search_filter' in data and isinstance(data['cdb_search_filter'], list): + # Already a list, keep it + pass + elif 'cdb_search_filter' in request.data: + # FormData sends as multiple values with same key + data['cdb_search_filter'] = request.data.getlist('cdb_search_filter') + + if 'members' in request.data: + if isinstance(request.data.get('members'), list): + data['members'] = request.data['members'] + else: + data['members'] = request.data.getlist('members') + + serializer = ProjectAnnotateEntitiesSerializer(project, data=data, partial=True) + if serializer.is_valid(): + project = serializer.save() + # Handle many-to-many fields manually if needed + if 'cdb_search_filter' in data: + project.cdb_search_filter.set(data['cdb_search_filter']) + if 'members' in data: + project.members.set(data['members']) + return Response(ProjectAnnotateEntitiesSerializer(project).data) + return Response(serializer.errors, status=400) + + elif request.method == 'DELETE': + project.delete() + return Response({'message': 'Project deleted successfully'}, status=200) + + +@api_view(http_method_names=['POST']) +@permission_classes([permissions.IsAuthenticated]) +def project_admin_create(request): + """ + Create a new project (user must be authenticated). + """ + # Handle both JSON and FormData + data = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data) + + # Convert many-to-many fields from FormData format + if 'cdb_search_filter' in request.data: + if isinstance(request.data.get('cdb_search_filter'), list): + data['cdb_search_filter'] = request.data['cdb_search_filter'] + else: + data['cdb_search_filter'] = request.data.getlist('cdb_search_filter') + + if 'members' in request.data: + if isinstance(request.data.get('members'), list): + data['members'] = request.data['members'] + else: + data['members'] = request.data.getlist('members') + + serializer = ProjectAnnotateEntitiesSerializer(data=data) + if serializer.is_valid(): + project = serializer.save() + # Handle many-to-many fields manually + if 'cdb_search_filter' in data: + project.cdb_search_filter.set(data['cdb_search_filter']) + if 'members' in data: + project.members.set(data['members']) + # Add the creator as a member if not already included + if request.user not in project.members.all(): + project.members.add(request.user) + return Response(ProjectAnnotateEntitiesSerializer(project).data, status=201) + return Response(serializer.errors, status=400) + + +@api_view(http_method_names=['POST']) +@permission_classes([permissions.IsAuthenticated]) +def project_admin_reset(request, project_id): + """ + Reset a project (clear all annotations) - only if user is project admin. + This is equivalent to the reset_project admin action. + """ + try: + project = ProjectAnnotateEntities.objects.get(id=project_id) + except ProjectAnnotateEntities.DoesNotExist: + return Response({'error': 'Project not found'}, status=404) + + # Check if user is project admin + from .permissions import is_project_admin + if not is_project_admin(request.user, project): + return Response({'error': 'You do not have permission to reset this project'}, status=403) + + # Remove all annotations and cascade to meta anns + AnnotatedEntity.objects.filter(project=project).delete() + + # Clear validated_documents and prepared_documents + project.validated_documents.clear() + project.prepared_documents.clear() + + return Response({'message': 'Project reset successfully'}, status=200) diff --git a/medcat-trainer/webapp/api/core/urls.py b/medcat-trainer/webapp/api/core/urls.py index e4165a52f..6614244d4 100644 --- a/medcat-trainer/webapp/api/core/urls.py +++ b/medcat-trainer/webapp/api/core/urls.py @@ -60,6 +60,10 @@ path('api/generate-concept-filter-json/', api.views.generate_concept_filter_flat_json), path('api/generate-concept-filter/', api.views.generate_concept_filter), path('api/cuis-to-concepts/', api.views.cuis_to_concepts), + path('api/project-admin/projects/', api.views.project_admin_projects), + path('api/project-admin/projects//', api.views.project_admin_detail), + path('api/project-admin/projects//reset/', api.views.project_admin_reset), + path('api/project-admin/projects/create/', api.views.project_admin_create), path('reset_password/', api.views.ResetPasswordView.as_view(), name='reset_password'), path('reset_password_sent/', pw_views.PasswordResetDoneView.as_view(), name='password_reset_done'), path('reset//', pw_views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'), diff --git a/medcat-trainer/webapp/frontend/src/App.vue b/medcat-trainer/webapp/frontend/src/App.vue index f58626418..f6a9bc177 100644 --- a/medcat-trainer/webapp/frontend/src/App.vue +++ b/medcat-trainer/webapp/frontend/src/App.vue @@ -17,6 +17,7 @@ @@ -472,7 +475,7 @@

Are you sure you want to delete the dataset {{ datasetToDelete.name }}?

This action cannot be undone.

- +
@@ -1971,7 +1974,6 @@ export default { } :deep(.modal-header) { - background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); padding: 20px 24px; margin: 0; border-bottom: 1px solid var(--color-border); From 5cba6955d6bd313e089f501d8bcd9979441bb6a5 Mon Sep 17 00:00:00 2001 From: Tom Searle Date: Fri, 6 Feb 2026 12:56:44 +0000 Subject: [PATCH 24/26] fix(medcat-trainer): fix styling for admin forms not showing action buttons. Refactor common styles to _admin.scss --- .../webapp/frontend/package-lock.json | 29 +- .../src/components/admin/DatasetForm.vue | 184 +------- .../src/components/admin/DatasetsList.vue | 89 +--- .../src/components/admin/ModelPackForm.vue | 229 ++------- .../src/components/admin/ModelPacksList.vue | 89 +--- .../src/components/admin/ProjectsList.vue | 57 +-- .../src/components/admin/UserForm.vue | 198 +------- .../src/components/admin/UsersList.vue | 85 +--- .../webapp/frontend/src/styles/_admin.scss | 445 ++++++++++++++++++ .../frontend/src/views/ProjectAdmin.vue | 5 +- 10 files changed, 518 insertions(+), 892 deletions(-) create mode 100644 medcat-trainer/webapp/frontend/src/styles/_admin.scss diff --git a/medcat-trainer/webapp/frontend/package-lock.json b/medcat-trainer/webapp/frontend/package-lock.json index 3990464d3..2689d9df5 100644 --- a/medcat-trainer/webapp/frontend/package-lock.json +++ b/medcat-trainer/webapp/frontend/package-lock.json @@ -4633,10 +4633,12 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -5160,9 +5162,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -5339,10 +5341,11 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -6866,9 +6869,9 @@ } }, "node_modules/vite": { - "version": "6.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", - "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/DatasetForm.vue b/medcat-trainer/webapp/frontend/src/components/admin/DatasetForm.vue index 8af7d49a5..72bf3d3a6 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/DatasetForm.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/DatasetForm.vue @@ -122,153 +122,18 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/DatasetsList.vue b/medcat-trainer/webapp/frontend/src/components/admin/DatasetsList.vue index e60b4c5ce..8f469d4fc 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/DatasetsList.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/DatasetsList.vue @@ -62,92 +62,5 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/ModelPackForm.vue b/medcat-trainer/webapp/frontend/src/components/admin/ModelPackForm.vue index 597006be6..1fae94eb2 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/ModelPackForm.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/ModelPackForm.vue @@ -13,25 +13,31 @@
- +
- - + + Upload a .zip file containing the model pack
+
+ +
-
+
-
- @@ -78,6 +84,7 @@ export default { emits: ['close', 'save'], data() { return { + showLegacyFields: false, formData: { name: '', model_pack: null, @@ -97,10 +104,27 @@ export default { concept_db: newVal.concept_db || null, vocab: newVal.vocab || null } + // Show legacy fields if concept_db or vocab are set + this.showLegacyFields = !!(newVal.concept_db || newVal.vocab) } else { this.resetForm() } } + }, + showLegacyFields(newVal) { + if (newVal) { + // When legacy mode is enabled, clear the model pack file + this.formData.model_pack = null + // Clear the file input element if it exists + const fileInput = this.$el?.querySelector('input[type="file"]') + if (fileInput) { + fileInput.value = '' + } + } else { + // When legacy mode is disabled, clear legacy fields + this.formData.concept_db = null + this.formData.vocab = null + } } }, methods: { @@ -111,6 +135,7 @@ export default { } }, resetForm() { + this.showLegacyFields = false this.formData = { name: '', model_pack: null, @@ -123,193 +148,23 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/ModelPacksList.vue b/medcat-trainer/webapp/frontend/src/components/admin/ModelPacksList.vue index dabe84bee..c4946fe05 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/ModelPacksList.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/ModelPacksList.vue @@ -87,92 +87,5 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/ProjectsList.vue b/medcat-trainer/webapp/frontend/src/components/admin/ProjectsList.vue index 91333e1af..e77e499da 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/ProjectsList.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/ProjectsList.vue @@ -124,6 +124,9 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/UserForm.vue b/medcat-trainer/webapp/frontend/src/components/admin/UserForm.vue index 9a9e0bc79..e8a0c93ce 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/UserForm.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/UserForm.vue @@ -118,202 +118,10 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/UsersList.vue b/medcat-trainer/webapp/frontend/src/components/admin/UsersList.vue index bd34df26c..63d0e4017 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/UsersList.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/UsersList.vue @@ -72,88 +72,5 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/styles/_admin.scss b/medcat-trainer/webapp/frontend/src/styles/_admin.scss new file mode 100644 index 000000000..1383e7e66 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/styles/_admin.scss @@ -0,0 +1,445 @@ +// Shared admin component styles +@import './variables.scss'; + +// ============================================ +// Form Container Styles +// ============================================ + +.form-section { + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: hidden; + display: flex; + flex-direction: column; + max-height: calc(100vh - 270px); + min-height: auto; +} + +.form-header { + padding: 12px 20px; + border-bottom: 1px solid var(--color-border); + background: linear-gradient(135deg, $primary 0%, darken($primary, 10%) 100%); + color: white; + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + border-radius: 12px 12px 0 0; + + .btn-back { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.3); + } + } + + h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + } +} + +.form-content { + flex: 1; + overflow: hidden; + padding: 16px 20px; + display: flex; + flex-direction: column; +} + +.admin-form { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + + .form-sections-wrapper { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + padding: 20px; + background: #f8f9fa; + } + + .form-actions { + margin-top: auto; + flex-shrink: 0; + padding: 20px; + border-top: 1px solid var(--color-border); + display: flex; + gap: 12px; + justify-content: flex-end; + background: var(--color-background-light); + } +} + +// ============================================ +// Form Section Styles +// ============================================ + +.form-section { + margin-bottom: 24px; + padding: 20px; + background: white; + border: 1px solid #e0e0e0; + border-radius: 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + flex-shrink: 0; + + h4 { + margin-bottom: 16px; + margin-top: 0; + color: var(--color-heading); + font-size: 1.05rem; + font-weight: 600; + padding-bottom: 12px; + border-bottom: 1px solid #f0f0f0; + } + + &.form-section-horizontal { + // Only .form-row elements should be horizontal, not the entire section + // This allows h4 headers to remain full width (ProjectAdmin pattern) + // Components that need the section itself horizontal (like ModelPackForm) can override + + .form-row { + display: flex; + gap: 20px; + align-items: flex-end; + flex-wrap: wrap; + margin-bottom: 16px; + + &:last-child { + margin-bottom: 0; + } + + .form-group { + margin-bottom: 0; + flex: 1; + min-width: 200px; + } + } + } +} + +// ============================================ +// Form Group & Control Styles +// ============================================ + +.form-group { + margin-bottom: 16px; + flex: 1; + min-width: 200px; + + label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--color-heading); + font-size: 0.9rem; + transition: color 0.2s ease; + } + + &:has(.form-control:disabled) label, + &:has(input:disabled) label { + color: #6c757d; + opacity: 0.7; + } + + .form-control { + width: 100%; + padding: 8px 12px; + border: 1px solid #d0d0d0; + border-radius: 8px; + font-size: 0.9rem; + transition: all 0.2s ease; + background: white; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.02); + + &:hover:not(:disabled) { + border-color: #b0b0b0; + } + + &:focus:not(:disabled) { + outline: none; + border-color: $primary; + box-shadow: 0 0 0 3px rgba(0, 114, 206, 0.1), inset 0 1px 2px rgba(0, 0, 0, 0.02); + } + + &:disabled { + background-color: #f5f5f5; + border-color: #d0d0d0; + color: #6c757d; + opacity: 0.6; + cursor: not-allowed; + box-shadow: none; + } + + &::placeholder { + color: #999; + opacity: 0.7; + } + } + + // Select dropdown arrow + select.form-control { + cursor: pointer; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 16px 12px; + padding-right: 32px; + } + + // Textarea specific styles + textarea.form-control { + resize: vertical; + min-height: 80px; + font-family: inherit; + line-height: 1.5; + border-radius: 8px; + } + + // Multiple select styles + select[multiple].form-control { + min-height: 120px; + padding: 8px; + border-radius: 8px; + + option { + padding: 6px 8px; + } + } + + .form-text { + display: block; + margin-top: 6px; + font-size: 0.85rem; + color: var(--color-text); + opacity: 0.7; + transition: opacity 0.2s ease; + } + + &:has(.form-control:disabled) .form-text, + &:has(input:disabled) .form-text { + opacity: 0.5; + } +} + +// ============================================ +// File Input Styles +// ============================================ + +input[type="file"].form-control, +.file-input { + padding: 8px; + cursor: pointer; + border: 1px solid #d0d0d0; + border-radius: 8px; + background: white; + display: block; + width: 100%; + min-height: 38px; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + border-color: #b0b0b0; + } + + &:disabled { + background-color: #f5f5f5; + border-color: #d0d0d0; + color: #6c757d; + opacity: 0.6; + cursor: not-allowed; + box-shadow: none; + } + + &::file-selector-button { + padding: 6px 14px; + margin-right: 12px; + border: 1px solid #d0d0d0; + border-radius: 6px; + background: #f8f9fa; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.85rem; + display: inline-block; + visibility: visible; + opacity: 1; + + &:hover { + background: #e9ecef; + border-color: #b0b0b0; + } + } + + &:disabled::file-selector-button { + background: #e9ecef; + border-color: #d0d0d0; + color: #6c757d; + opacity: 0.6; + cursor: not-allowed; + } +} + +// ============================================ +// Checkbox Styles +// ============================================ + +.checkbox-group { + margin-bottom: 16px; + + .checkbox-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + padding: 8px 0; + transition: all 0.2s ease; + margin-bottom: 0; + min-height: 36px; + + &:hover { + opacity: 0.8; + } + + .checkbox-input { + margin: 0; + width: 18px; + height: 18px; + cursor: pointer; + accent-color: $primary; + flex-shrink: 0; + border: 1px solid #d0d0d0; + border-radius: 3px; + } + + .checkbox-text { + flex: 1; + font-weight: 400; + color: var(--color-text); + font-size: 0.9rem; + line-height: 1.4; + } + } +} + +.checkbox-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 8px; +} + +// ============================================ +// List Section Styles +// ============================================ + +.list-section { + .section-header { + margin-bottom: 20px; + + h3 { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-heading); + margin: 0; + } + + .item-count { + font-weight: 400; + color: var(--color-text-secondary); + font-size: 1rem; + } + } + + .table-container { + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: hidden; + } + + .action-buttons { + display: flex; + gap: 6px; + justify-content: flex-start; + } + + .btn-action { + padding: 4px 8px; + border: none; + background: transparent; + cursor: pointer; + transition: all 0.2s ease; + border-radius: 4px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.btn-edit { + color: #0d6efd; + } + + &.btn-clone { + color: #0d6efd; + } + + &.btn-reset { + color: #ffc107; + } + + &.btn-delete { + color: #dc3545; + } + } + + .empty-state { + text-align: center; + padding: 60px 20px; + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + h4 { + font-size: 1.25rem; + color: var(--color-heading); + margin-bottom: 8px; + } + + p { + color: var(--color-text-secondary); + margin-bottom: 20px; + } + + .btn-create-empty { + margin-top: 10px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + font-weight: 500; + border-radius: 6px; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 114, 206, 0.2); + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 114, 206, 0.3); + } + } + } +} \ No newline at end of file diff --git a/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue b/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue index 262b69c46..c3625b84c 100644 --- a/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue +++ b/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue @@ -1131,6 +1131,7 @@ export default { diff --git a/medcat-trainer/webapp/frontend/vite.config.ts b/medcat-trainer/webapp/frontend/vite.config.ts index fba0599d6..5698c38f2 100644 --- a/medcat-trainer/webapp/frontend/vite.config.ts +++ b/medcat-trainer/webapp/frontend/vite.config.ts @@ -18,7 +18,17 @@ export default defineConfig({ }, build: { sourcemap: true, - assetsDir: 'static' + assetsDir: 'static', + chunkSizeWarningLimit: 1000, + rollupOptions: { + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router'], + 'vuetify-vendor': ['vuetify'], + 'plotly-vendor': ['plotly.js-dist'] + } + } + } }, server: { host: '127.0.0.1', From 656fef163a21ab5b082e7110fbaf9916ba06a326 Mon Sep 17 00:00:00 2001 From: Tom Searle Date: Mon, 9 Feb 2026 15:31:51 +0000 Subject: [PATCH 26/26] feat(medcat-trainer): refactor project-admin styles, improve style consistency between buttons, action buttons, improve validation notifications, fix cdb_search_filter being set from model pack or concept_db --- medcat-trainer/webapp/api/api/views.py | 90 +- medcat-trainer/webapp/frontend/package.json | 4 +- .../src/components/admin/DatasetForm.vue | 166 ++-- .../src/components/admin/DatasetsList.vue | 5 +- .../src/components/admin/ModelPackForm.vue | 161 +++- .../src/components/admin/ModelPacksList.vue | 5 +- .../src/components/admin/UserForm.vue | 79 +- .../src/components/admin/UsersList.vue | 2 +- .../src/components/common/ConceptPicker.vue | 13 +- .../webapp/frontend/src/styles/_admin.scss | 622 ++++++++++++- .../frontend/src/views/ProjectAdmin.vue | 853 ++++++------------ 11 files changed, 1273 insertions(+), 727 deletions(-) diff --git a/medcat-trainer/webapp/api/api/views.py b/medcat-trainer/webapp/api/api/views.py index b9e6961eb..43efddab4 100644 --- a/medcat-trainer/webapp/api/api/views.py +++ b/medcat-trainer/webapp/api/api/views.py @@ -1135,25 +1135,25 @@ def project_admin_detail(request, project_id): # Handle both JSON and FormData data = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data) - # Extract many-to-many fields before serializer validation + # Extract many-to-many fields - handle both JSON (list) and FormData (getlist) cdb_search_filter_ids = [] - if 'cdb_search_filter' in request.data: - if isinstance(request.data.get('cdb_search_filter'), list): - cdb_search_filter_ids = request.data['cdb_search_filter'] - else: - # FormData sends as multiple values with same key - cdb_search_filter_ids = request.data.getlist('cdb_search_filter') - # Remove from data dict so serializer doesn't try to validate it - data.pop('cdb_search_filter', None) + try: + cdb_search_filter_ids = [int(x) for x in request.data['cdb_search_filter'] if x] + except (ValueError, TypeError) as e: + logger.warning(f"Error parsing cdb_search_filter: {e}") + cdb_search_filter_ids = [] members_ids = [] - if 'members' in request.data: - if isinstance(request.data.get('members'), list): - members_ids = request.data['members'] - else: - members_ids = request.data.getlist('members') - # Remove from data dict so serializer doesn't try to validate it - data.pop('members', None) + try: + members_ids = [int(x) for x in request.data['members'] if x] + except (ValueError, TypeError) as e: + logger.warning(f"Error parsing members: {e}") + members_ids = [] + + # Set many-to-many fields to the extracted IDs (or empty list) + # This satisfies serializer validation, then we'll set them properly after save + data['members'] = members_ids if members_ids else [] + data['cdb_search_filter'] = cdb_search_filter_ids if cdb_search_filter_ids else [] # Convert string booleans to actual booleans boolean_fields = ['project_locked', 'annotation_classification', 'require_entity_validation', @@ -1192,37 +1192,53 @@ def project_admin_create(request): Create a new project (user must be authenticated). """ # Handle both JSON and FormData - data = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data) - - # Convert many-to-many fields from FormData format - if 'cdb_search_filter' in request.data: + # Extract many-to-many fields - handle both JSON (list) and FormData (getlist) + cdb_search_filter_ids = [] + try: if isinstance(request.data.get('cdb_search_filter'), list): - data['cdb_search_filter'] = request.data['cdb_search_filter'] - else: + # JSON request - already a list + cdb_search_filter_ids = [int(x) for x in request.data['cdb_search_filter'] if x] + elif hasattr(request.data, 'getlist'): + # FormData request - use getlist() cdb_filter_list = request.data.getlist('cdb_search_filter') - # Only include if list has items, otherwise set to empty list - data['cdb_search_filter'] = cdb_filter_list if cdb_filter_list else [] - else: - data['cdb_search_filter'] = [] + if cdb_filter_list: + cdb_search_filter_ids = [int(x) for x in cdb_filter_list if x and str(x).strip()] + except (ValueError, TypeError) as e: + logger.warning(f"Error parsing cdb_search_filter: {e}") + cdb_search_filter_ids = [] - if 'members' in request.data: + members_ids = [] + try: if isinstance(request.data.get('members'), list): - data['members'] = request.data['members'] - else: + # JSON request - already a list + members_ids = [int(x) for x in request.data['members'] if x] + elif hasattr(request.data, 'getlist'): + # FormData request - use getlist() members_list = request.data.getlist('members') - # Only include if list has items - data['members'] = members_list if members_list else [] + if members_list: + members_ids = [int(x) for x in members_list if x and str(x).strip()] + except (ValueError, TypeError) as e: + logger.warning(f"Error parsing members: {e}") + members_ids = [] + + # Build data dict - use the actual member IDs we extracted, or empty list if none + # The serializer will validate with these, then we'll set them properly after save + if hasattr(request.data, 'copy'): + data = request.data.copy() else: - data['members'] = [] + data = dict(request.data) + + # Set many-to-many fields to the extracted IDs (or empty list) + # This satisfies serializer validation, then we'll set them properly after save + data['members'] = members_ids if members_ids else [] + data['cdb_search_filter'] = cdb_search_filter_ids if cdb_search_filter_ids else [] serializer = ProjectAnnotateEntitiesSerializer(data=data) if serializer.is_valid(): project = serializer.save() - # Handle many-to-many fields manually - if 'cdb_search_filter' in data: - project.cdb_search_filter.set(data['cdb_search_filter']) - if 'members' in data: - project.members.set(data['members']) + # Handle many-to-many fields manually after saving + project.cdb_search_filter.set(cdb_search_filter_ids) + project.members.set(members_ids) # Add the creator as a member if not already included if request.user not in project.members.all(): project.members.add(request.user) diff --git a/medcat-trainer/webapp/frontend/package.json b/medcat-trainer/webapp/frontend/package.json index 6f1ba97cc..a2de257c2 100644 --- a/medcat-trainer/webapp/frontend/package.json +++ b/medcat-trainer/webapp/frontend/package.json @@ -4,13 +4,13 @@ "private": true, "type": "module", "scripts": { - "dev": "NODE_OPTIONS=--max-old-space-size=4096 vite", + "dev": "NODE_OPTIONS=--max-old-space-size=8192 vite", "build": "run-p type-check \"build-only {@}\" --", "preview": "vite preview", "test:unit": "vitest", "coverage": "vitest run --coverage", "test:ui": "vitest --ui", - "build-only": "NODE_OPTIONS=--max-old-space-size=4096 vite build", + "build-only": "NODE_OPTIONS=--max-old-space-size=8192 vite build", "type-check": "vue-tsc --build --force", "lint": "eslint . --fix", "format": "prettier --write src/" diff --git a/medcat-trainer/webapp/frontend/src/components/admin/DatasetForm.vue b/medcat-trainer/webapp/frontend/src/components/admin/DatasetForm.vue index 72bf3d3a6..f58624527 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/DatasetForm.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/DatasetForm.vue @@ -8,12 +8,23 @@

{{ editing ? 'Edit Dataset' : 'Add Dataset' }}

-
+
- + + {{ validationErrors.name }}
@@ -23,7 +34,18 @@
- + + {{ validationErrors.original_file }}
File Schema Requirements: @@ -84,7 +106,8 @@ export default { name: '', description: '', original_file: null - } + }, + validationErrors: {} } }, watch: { @@ -100,14 +123,80 @@ export default { } else { this.resetForm() } + // Clear validation errors when dataset changes + this.validationErrors = {} + } + }, + 'formData.name'() { + // Clear error when user starts typing + if (this.validationErrors.name) { + delete this.validationErrors.name } } }, methods: { + handleInvalid(event) { + event.preventDefault() + const field = event.target + const fieldName = field.name || field.getAttribute('data-field') + if (fieldName && this.validationErrors[fieldName]) { + field.setCustomValidity(this.validationErrors[fieldName]) + } + }, + clearValidationError(fieldName) { + if (this.validationErrors[fieldName]) { + delete this.validationErrors[fieldName] + const field = this.$el?.querySelector(`[data-field="${fieldName}"], [name="${fieldName}"]`) + if (field) { + field.setCustomValidity('') + field.classList.remove('is-invalid') + } + } + }, + validateForm() { + this.validationErrors = {} + let isValid = true + + if (!this.formData.name || this.formData.name.trim() === '') { + this.validationErrors.name = 'Dataset name is required' + isValid = false + } + + if (!this.editing && !this.formData.original_file) { + this.validationErrors.original_file = 'Original file is required' + isValid = false + } + + // Set HTML5 validation messages + if (!isValid) { + this.$nextTick(() => { + Object.keys(this.validationErrors).forEach(fieldName => { + const field = this.$el?.querySelector(`[data-field="${fieldName}"], [name="${fieldName}"]`) + if (field && this.validationErrors[fieldName]) { + field.setCustomValidity(this.validationErrors[fieldName]) + field.classList.add('is-invalid') + } + }) + }) + } + + return isValid + }, + handleSubmit() { + if (this.validateForm()) { + this.$emit('save', this.formData) + } else { + this.$toast?.error('Please fix the validation errors before saving') + } + }, handleFileChange(event) { const file = event.target.files[0] if (file) { this.formData.original_file = file + // Clear error when file is selected + if (this.validationErrors.original_file) { + delete this.validationErrors.original_file + } } }, resetForm() { @@ -116,6 +205,7 @@ export default { description: '', original_file: null } + this.validationErrors = {} } } } @@ -129,9 +219,6 @@ export default { max-height: calc(100vh - 270px); } -.admin-form { - height: calc(100% - 70px); -} .form-group { textarea.form-control { @@ -141,70 +228,5 @@ export default { line-height: 1.5; border-radius: 8px; } - - .schema-guide { - margin-top: 12px; - padding: 16px; - background: #f8f9fa; - border: 1px solid #e0e0e0; - border-radius: 8px; - - .form-text { - margin-top: 0; - margin-bottom: 8px; - font-weight: 500; - opacity: 1; - color: var(--color-heading); - } - - .schema-list { - margin: 8px 0 12px 0; - padding-left: 20px; - color: var(--color-text); - font-size: 0.9rem; - line-height: 1.6; - - li { - margin-bottom: 6px; - - code { - background: #e9ecef; - padding: 2px 6px; - border-radius: 4px; - font-size: 0.85em; - color: #d63384; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - } - - ul { - margin-top: 4px; - margin-bottom: 4px; - padding-left: 20px; - } - } - } - - .example-text { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid #e0e0e0; - display: block; - font-size: 0.85rem; - line-height: 1.8; - - code { - display: block; - background: #f1f3f5; - padding: 8px 12px; - border-radius: 6px; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 0.85em; - color: #495057; - margin-top: 4px; - white-space: pre; - overflow-x: auto; - } - } - } } diff --git a/medcat-trainer/webapp/frontend/src/components/admin/DatasetsList.vue b/medcat-trainer/webapp/frontend/src/components/admin/DatasetsList.vue index 8f469d4fc..165346750 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/DatasetsList.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/DatasetsList.vue @@ -15,7 +15,10 @@ dense> diff --git a/medcat-trainer/webapp/frontend/src/components/common/ConceptPicker.vue b/medcat-trainer/webapp/frontend/src/components/common/ConceptPicker.vue index 5e19f95d1..4acab4c52 100644 --- a/medcat-trainer/webapp/frontend/src/components/common/ConceptPicker.vue +++ b/medcat-trainer/webapp/frontend/src/components/common/ConceptPicker.vue @@ -60,18 +60,27 @@ export default { return { selectedCUI: null, searchResults: [], - loadingResults: false + loadingResults: false, + error: null } }, watch: { 'selectedCUI' (newVal) { this.$emit('pickedResult:concept', newVal) }, - 'selection': 'selectionChange' + 'selection' (newVal) { + if (newVal) { + this.selectedCUI = this.searchResults.find(r => r.cui === newVal) || null + if (this.selectedCUI) { + this.searchCUI(newVal) + } + } + } }, methods: { searchCUI: _.debounce(function (term) { this.loadingResults = true + this.error = null if (!term || term.trim().length === 0) { this.loadingResults = false diff --git a/medcat-trainer/webapp/frontend/src/styles/_admin.scss b/medcat-trainer/webapp/frontend/src/styles/_admin.scss index 1383e7e66..e9272cc00 100644 --- a/medcat-trainer/webapp/frontend/src/styles/_admin.scss +++ b/medcat-trainer/webapp/frontend/src/styles/_admin.scss @@ -86,6 +86,84 @@ } } +// ============================================ +// Form and modal action buttons (consistent styling) +// ============================================ + +.admin-form .form-actions, +.project-form .form-actions, +.confirm-modal .form-actions { + .btn { + padding: 10px 20px; + font-weight: 500; + border-radius: 6px; + transition: all 0.2s ease; + min-width: 100px; + border: 1px solid transparent; + cursor: pointer; + font-size: 0.95rem; + + &:hover:not(:disabled) { + transform: translateY(-1px); + } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + &.btn-primary { + background-color: $primary; + color: white; + box-shadow: 0 2px 4px rgba(0, 114, 206, 0.2); + + &:hover:not(:disabled) { + background-color: darken($primary, 6%); + box-shadow: 0 4px 8px rgba(0, 114, 206, 0.3); + } + } + + &.btn-secondary, + &.btn-default { + background-color: #f0f0f0; + color: #333; + border-color: #d0d0d0; + + &:hover:not(:disabled) { + background-color: #e5e5e5; + border-color: #b0b0b0; + } + } + + &.btn-danger { + background-color: $danger; + color: white; + + &:hover:not(:disabled) { + background-color: darken($danger, 6%); + } + } + + &.btn-warning { + background-color: $warning; + color: white; + + &:hover:not(:disabled) { + background-color: darken($warning, 8%); + } + } + + &.btn-success { + background-color: $success; + color: white; + + &:hover:not(:disabled) { + background-color: darken($success, 6%); + } + } + } +} + // ============================================ // Form Section Styles // ============================================ @@ -191,6 +269,25 @@ color: #999; opacity: 0.7; } + + // Validation error state + &.is-invalid { + border-color: $danger; + box-shadow: 0 0 0 3px rgba(218, 41, 28, 0.1), inset 0 1px 2px rgba(0, 0, 0, 0.02); + + &:focus { + border-color: $danger; + box-shadow: 0 0 0 3px rgba(218, 41, 28, 0.15), inset 0 1px 2px rgba(0, 0, 0, 0.02); + } + } + } + + // Error message styling + .form-text.text-danger { + color: $danger; + font-size: 0.85rem; + margin-top: 4px; + display: block; } // Select dropdown arrow @@ -376,37 +473,150 @@ input[type="file"].form-control, display: flex; gap: 6px; justify-content: flex-start; + + .btn-action { + padding: 4px 8px; + border-radius: 4px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + font-size: 0.85rem; + border: 1px solid transparent; + + &:hover { + transform: translateY(-1px); + } + + &.btn-clone { + color: $success; + border-color: $success; + + &:hover { + background-color: $success; + color: white; + } + } + + &.btn-reset { + color: $warning; + border-color: $warning; + + &:hover { + background-color: $warning; + color: white; + } + } + + &.btn-delete { + color: $danger; + border-color: $danger; + + &:hover { + background-color: $danger; + color: white; + } + } + + // Fallback for text-only action buttons (simpler style) + &:not(.btn-clone):not(.btn-reset):not(.btn-delete) { + border: none; + background: transparent; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.btn-edit { + color: #0d6efd; + } + } + } + } + + .empty-state { + text-align: center; + padding: 60px 20px; + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } +} + +// Action buttons styles - apply to all list sections +.list-section .action-buttons, +.project-list-section .action-buttons, +.action-buttons { + display: flex; + gap: 6px; + justify-content: flex-start; .btn-action { padding: 4px 8px; - border: none; - background: transparent; - cursor: pointer; - transition: all 0.2s ease; border-radius: 4px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + font-size: 0.85rem; + border: 1px solid transparent; &:hover { - background: rgba(0, 0, 0, 0.05); - } - - &.btn-edit { - color: #0d6efd; + transform: translateY(-1px); } &.btn-clone { - color: #0d6efd; + color: $success; + border-color: $success; + + &:hover { + background-color: $success; + color: white; + } } &.btn-reset { - color: #ffc107; + color: $warning; + border-color: $warning; + + &:hover { + background-color: $warning; + color: white; + } } &.btn-delete { - color: #dc3545; + color: $danger; + border-color: $danger; + + &:hover { + background-color: $danger; + color: white; + } + } + + // Fallback for text-only action buttons (simpler style) + &:not(.btn-clone):not(.btn-reset):not(.btn-delete) { + border: none; + background: transparent; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.btn-edit { + color: #0d6efd; + } } } +} +// Legacy support - keep old empty-state reference +.list-section { .empty-state { text-align: center; padding: 60px 20px; @@ -442,4 +652,392 @@ input[type="file"].form-control, } } } +} + +// ============================================ +// Admin Tab Navigation (reusable across admin views) +// ============================================ + +.admin-tabs { + display: flex; + gap: 8px; + margin: 20px 0; + border-bottom: 2px solid var(--color-border); + padding-bottom: 0; + flex-shrink: 0; +} + +.project-admin-content, +.admin-tab-content { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + + .tab-content { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + } +} + +.tab-button { + padding: 12px 24px; + background: transparent; + border: none; + border-bottom: 3px solid transparent; + cursor: pointer; + font-size: 0.95rem; + font-weight: 500; + color: var(--color-text-secondary); + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; + margin-bottom: -2px; + + &:hover { + color: var(--color-primary); + background: rgba(0, 0, 0, 0.02); + } + + &.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); + font-weight: 600; + } + + svg { + font-size: 1rem; + } +} + +// ============================================ +// Badge Styles (reusable across admin) +// ============================================ + +.badge { + padding: 4px 10px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; + white-space: nowrap; + + &.badge-primary { + background-color: rgba(0, 114, 206, 0.1); + color: $primary; + border: 1px solid rgba(0, 114, 206, 0.2); + } + + &.badge-success { + background-color: rgba(0, 150, 57, 0.1); + color: $success; + border: 1px solid rgba(0, 150, 57, 0.2); + } + + &.badge-danger { + background-color: rgba(218, 41, 28, 0.1); + color: $danger; + border: 1px solid rgba(218, 41, 28, 0.2); + } + + &.badge-secondary { + background-color: rgba(108, 117, 125, 0.1); + color: #6c757d; + border: 1px solid rgba(108, 117, 125, 0.2); + } +} + +// Standalone empty state (e.g. for project list when no projects) +.empty-state { + text-align: center; + padding: 60px 20px; + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + h4 { + font-size: 1.25rem; + color: var(--color-heading); + margin-bottom: 8px; + } + + p { + color: var(--color-text-secondary); + margin-bottom: 20px; + } +} + +// ============================================ +// Loading Container (reusable across admin) +// ============================================ + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + padding: 80px 40px; + min-height: 400px; + + .loading-text { + color: var(--color-text); + font-size: 1.1rem; + opacity: 0.8; + } +} + +// ============================================ +// Schema Guide (reusable for file upload forms) +// ============================================ + +.schema-guide { + margin-top: 12px; + padding: 16px; + background: #f8f9fa; + border: 1px solid #e0e0e0; + border-radius: 8px; + + .form-text { + margin-top: 0; + margin-bottom: 8px; + font-weight: 500; + opacity: 1; + color: var(--color-heading); + } + + .schema-list { + margin: 8px 0 12px 0; + padding-left: 20px; + color: var(--color-text); + font-size: 0.9rem; + line-height: 1.6; + + li { + margin-bottom: 6px; + + code { + background: #e9ecef; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.85em; + color: #d63384; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + } + + ul { + margin-top: 4px; + margin-bottom: 4px; + padding-left: 20px; + } + } + } + + .example-text { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e0e0e0; + display: block; + font-size: 0.85rem; + line-height: 1.8; + + code { + display: block; + background: #f1f3f5; + padding: 8px 12px; + border-radius: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.85em; + color: #495057; + margin-top: 4px; + white-space: pre; + overflow-x: auto; + } + } +} + +// ============================================ +// Confirm Modal (reusable confirmation dialogs) +// ============================================ + +.confirm-modal { + :deep(.modal-container) { + width: 500px; + border-radius: 8px; + } + + :deep(.modal-header) { + padding: 20px 24px; + margin: 0; + border-bottom: 1px solid var(--color-border); + + h3 { + color: var(--color-heading); + margin: 0; + } + } + + :deep(.modal-body) { + padding: 24px; + } + + .confirm-content { + .project-name-highlight { + color: $primary; + font-weight: 600; + } + + .warning-text { + padding: 12px; + background-color: rgba(218, 41, 28, 0.1); + border-left: 3px solid $danger; + border-radius: 4px; + margin: 16px 0; + } + + .text-warning { + padding: 12px; + background-color: rgba(118, 134, 146, 0.1); + border-left: 3px solid $warning; + border-radius: 4px; + margin: 16px 0; + } + } + + .form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 24px; + } +} + +// ============================================ +// Concept UI Filter Styles (reusable filtering components) +// ============================================ + +.cui-filter-controls { + margin: 0 0 16px 0; + padding: 12px; + background: #f8f9fa; + border: 1px solid #e0e0e0; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.cui-filter-checkbox { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 0.9rem; + cursor: pointer; + font-weight: 400; + color: var(--color-text); + + input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: $primary; + border: 1px solid #d0d0d0; + } +} + +.cui-filter-paste-toggle { + padding: 4px 8px; + font-size: 0.85rem; + border: 1px solid #d0d0d0; + border-radius: 6px; + background: white; + transition: all 0.2s ease; + + &:hover { + background: #f0f0f0; + border-color: #b0b0b0; + } +} + +.cui-filter-row { + display: flex; + gap: 20px; + align-items: flex-start; + margin: 0 0 16px 0; +} + +.cui-filter-picker { + flex: 0 0 50%; + max-width: 50%; + padding: 12px; + background: #fafafa; + border: 1px solid #e0e0e0; + border-radius: 8px; +} + +.cui-file-picker { + flex: 0 0 calc(50% - 20px); + max-width: calc(50% - 20px); + padding: 12px; + background: #fafafa; + border: 1px solid #e0e0e0; + border-radius: 8px; + + label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--color-heading); + font-size: 0.9rem; + } + + .form-control { + border: 1px solid #d0d0d0; + } +} + +.cui-pill-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 6px 0 10px 0; +} + +.cui-pill { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid rgba(0, 0, 0, 0.15); + background: rgba(13, 110, 253, 0.08); + color: #0b5ed7; + border-radius: 999px; + padding: 4px 10px; + font-size: 0.75rem; + line-height: 1; +} + +.cui-pill-text { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +.cui-pill-remove { + border: none; + background: transparent; + color: inherit; + padding: 0; + cursor: pointer; + font-size: 16px; + line-height: 1; + opacity: 0.7; + transition: opacity 0.2s ease; + + &:hover { + opacity: 1; + } } \ No newline at end of file diff --git a/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue b/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue index c3625b84c..acc165389 100644 --- a/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue +++ b/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue @@ -96,14 +96,36 @@
- + + {{ validationErrors.name }}
- + {{ validationErrors.dataset }}
@@ -149,10 +171,19 @@
- + {{ validationErrors.model_pack }}
- - + {{ validationErrors.concept_db }}
- - + {{ validationErrors.vocab }}
@@ -187,9 +234,26 @@
- - - URL of the remote MedCAT service API (e.g., http://medcat-service:8000). Note: interim model training is not supported for remote model service projects. + + + {{ validationErrors.model_service_url }} + URL of the remote MedCAT service API (e.g., http://medcat-service:8000). Note: interim model training is not supported for remote model service projects. +
+
+
+
+ {{ validationErrors.model_config }}
@@ -310,10 +374,8 @@
- - + @@ -332,7 +394,7 @@

Are you sure you want to delete the project {{ projectToDelete.name }}?

This action cannot be undone.

- +
@@ -349,7 +411,7 @@

Are you sure you want to reset the project {{ projectToReset.name }}?

This will remove all annotations and clear validated/prepared documents.

- +
@@ -375,7 +437,7 @@ />
- +
@@ -461,7 +523,7 @@

Are you sure you want to delete the model pack {{ modelPackToDelete.name }}?

This action cannot be undone.

- +
@@ -475,7 +537,7 @@

Are you sure you want to delete the dataset {{ datasetToDelete.name }}?

This action cannot be undone.

- +
@@ -567,6 +629,7 @@ export default { cuis_file: null, members: [] }, + validationErrors: {}, tableHeaders: [ { title: 'Name', value: 'name' }, { title: 'Description', value: 'description' }, @@ -667,6 +730,7 @@ export default { this.showCreateForm = false this.editingProject = null this.useBackupOption = false + this.validationErrors = {} this.selectedCuiFilterConcepts = [] this.includeSubConcepts = false this.showCuiFilterTextarea = false @@ -754,11 +818,88 @@ export default { } return null }, + handleInvalid(event) { + // Prevent browser's default validation message popup + event.preventDefault() + const field = event.target + const fieldName = field.name || field.id || field.getAttribute('data-field') + if (fieldName && this.validationErrors[fieldName]) { + field.setCustomValidity(this.validationErrors[fieldName]) + } + }, + clearValidationError(fieldName) { + if (this.validationErrors[fieldName]) { + delete this.validationErrors[fieldName] + // Clear HTML5 validation state + const field = this.$el?.querySelector(`[data-field="${fieldName}"], [name="${fieldName}"]`) + if (field) { + field.setCustomValidity('') + field.classList.remove('is-invalid') + } + } + }, + validateProjectForm() { + this.validationErrors = {} + let isValid = true + + // Required fields + if (!this.formData.name || this.formData.name.trim() === '') { + this.validationErrors.name = 'Project name is required' + isValid = false + } + + if (!this.formData.dataset) { + this.validationErrors.dataset = 'Dataset is required' + isValid = false + } + + // Model configuration validation + if (this.formData.use_model_service) { + if (!this.formData.model_service_url || this.formData.model_service_url.trim() === '') { + this.validationErrors.model_service_url = 'Model service URL is required when using remote model service' + isValid = false + } + } else { + // Must have either model_pack OR (concept_db AND vocab) + const hasModelPack = !!this.formData.model_pack + const hasBackupOption = this.useBackupOption && !!this.formData.concept_db && !!this.formData.vocab + + if (!hasModelPack && !hasBackupOption) { + this.validationErrors.model_config = 'Must set either a Model Pack or enable backup option with Concept DB and Vocabulary' + isValid = false + } + + // Cannot have both model_pack and backup option + if (hasModelPack && hasBackupOption) { + this.validationErrors.model_config = 'Cannot set both Model Pack and Concept DB/Vocabulary pair. Use one or the other.' + isValid = false + } + } + + // Set HTML5 validation messages + if (!isValid) { + this.$nextTick(() => { + Object.keys(this.validationErrors).forEach(fieldName => { + const field = this.$el?.querySelector(`[data-field="${fieldName}"], [name="${fieldName}"]`) + if (field && this.validationErrors[fieldName]) { + field.setCustomValidity(this.validationErrors[fieldName]) + field.classList.add('is-invalid') + } + }) + }) + } + + return isValid + }, async saveProject() { + // Validate before submitting + if (!this.validateProjectForm()) { + this.$toast?.error('Please fix the validation errors before saving') + return + } + this.saving = true try { - const formDataToSend = new FormData() - // Sync CUIs from pills before saving this.syncCuiTextFromPills() @@ -768,50 +909,63 @@ export default { this.formData.vocab = null } - // CDB Search Filter is hidden - will use ModelPack by default - // Clear it so backend uses ModelPack - this.formData.cdb_search_filter = [] - - // Add all form fields to FormData - Object.keys(this.formData).forEach(key => { - if (key === 'cuis_file' && this.formData[key]) { - formDataToSend.append(key, this.formData[key]) - } else if (key === 'cdb_search_filter' || key === 'members') { - // Handle arrays - send empty array as empty (don't append anything for empty arrays) - if (Array.isArray(this.formData[key]) && this.formData[key].length > 0) { - this.formData[key].forEach(val => { - if (val !== null && val !== undefined) { - formDataToSend.append(key, val) - } - }) - } - // For empty arrays, don't send anything (backend will handle as empty) - } else if (this.formData[key] !== null && this.formData[key] !== undefined) { - // Convert null-like values to empty strings for optional fields - const value = this.formData[key] - // For boolean false, send as string 'false' - if (typeof value === 'boolean') { - formDataToSend.append(key, value.toString()) - } else { - formDataToSend.append(key, value) - } + // Prepare data payload - convert members to integers + const payload = { ...this.formData } + + // Set cdb_search_filter to the linked concept_db: when using a model pack use its + // concept_db; when using backup option use the project's concept_db. + let conceptDbIdForFilter = null + if (payload.model_pack) { + const modelPack = this.modelPacks.find(mp => mp.id === payload.model_pack) + if (modelPack?.concept_db != null) { + conceptDbIdForFilter = typeof modelPack.concept_db === 'object' ? modelPack.concept_db.id : modelPack.concept_db } - }) + } else if (payload.concept_db) { + conceptDbIdForFilter = payload.concept_db + } + payload.cdb_search_filter = conceptDbIdForFilter != null ? [conceptDbIdForFilter] : [] + + // Ensure members are integers + if (Array.isArray(payload.members)) { + payload.members = payload.members + .map(val => { + if (val === null || val === undefined || val === '') return null + const numVal = typeof val === 'string' ? parseInt(val, 10) : Number(val) + return (!isNaN(numVal) && isFinite(numVal)) ? numVal : null + }) + .filter(val => val !== null) + } else { + payload.members = [] + } + + // Ensure cdb_search_filter are integers + if (Array.isArray(payload.cdb_search_filter)) { + payload.cdb_search_filter = payload.cdb_search_filter + .map(val => { + if (val === null || val === undefined || val === '') return null + const numVal = typeof val === 'string' ? parseInt(val, 10) : Number(val) + return (!isNaN(numVal) && isFinite(numVal)) ? numVal : null + }) + .filter(val => val !== null) + } else { + payload.cdb_search_filter = [] + } + + // Remove cuis_file from JSON payload (file uploads would need separate handling if needed) + delete payload.cuis_file let response if (this.editingProject) { // Update existing project response = await this.$http.put( `/api/project-admin/projects/${this.editingProject.id}/`, - formDataToSend, - { headers: { 'Content-Type': 'multipart/form-data' } } + payload ) } else { // Create new project response = await this.$http.post( '/api/project-admin/projects/create/', - formDataToSend, - { headers: { 'Content-Type': 'multipart/form-data' } } + payload ) } @@ -1117,12 +1271,64 @@ export default { this.syncPillsFromCuiText() } }, + 'formData.name'() { + if (this.validationErrors.name) { + delete this.validationErrors.name + } + }, + 'formData.dataset'() { + if (this.validationErrors.dataset) { + delete this.validationErrors.dataset + } + }, + 'formData.model_service_url'() { + if (this.validationErrors.model_service_url) { + delete this.validationErrors.model_service_url + } + }, 'formData.model_pack'() { // Clear pills when model pack changes to avoid confusion // User can re-select concepts with the new model pack if (!this.editingProject) { this.selectedCuiFilterConcepts = [] - this.formData.cuis = '' + } + // Clear validation errors + if (this.validationErrors.model_pack) { + delete this.validationErrors.model_pack + } + // Clear model_config error when model_pack is set + if (this.validationErrors.model_config && this.formData.model_pack) { + delete this.validationErrors.model_config + } + }, + 'formData.concept_db'() { + if (this.validationErrors.concept_db) { + delete this.validationErrors.concept_db + } + // Clear model_config error when both concept_db and vocab are set + if (this.validationErrors.model_config && this.formData.concept_db && this.formData.vocab) { + delete this.validationErrors.model_config + } + }, + 'formData.vocab'() { + if (this.validationErrors.vocab) { + delete this.validationErrors.vocab + } + // Clear model_config error when both concept_db and vocab are set + if (this.validationErrors.model_config && this.formData.concept_db && this.formData.vocab) { + delete this.validationErrors.model_config + } + }, + 'useBackupOption'() { + // Clear model_config error when switching modes + if (this.validationErrors.model_config) { + delete this.validationErrors.model_config + } + }, + 'formData.use_model_service'() { + // Clear model_config error when switching modes + if (this.validationErrors.model_config) { + delete this.validationErrors.model_config } } } @@ -1203,22 +1409,6 @@ export default { } } -.loading-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 20px; - padding: 80px 40px; - min-height: 400px; - - .loading-text { - color: var(--color-text); - font-size: 1.1rem; - opacity: 0.8; - } -} - .project-list-section { background: white; border-radius: 8px; @@ -1307,58 +1497,6 @@ export default { font-size: 0.9rem; } -.action-buttons { - display: flex; - gap: 6px; - justify-content: flex-end; - - .btn-action { - padding: 4px 8px; - border-radius: 4px; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - min-width: 32px; - height: 32px; - font-size: 0.85rem; - - &:hover { - transform: translateY(-1px); - } - - &.btn-clone { - color: $success; - border-color: $success; - - &:hover { - background-color: $success; - color: white; - } - } - - &.btn-reset { - color: $warning; - border-color: $warning; - - &:hover { - background-color: $warning; - color: white; - } - } - - &.btn-delete { - color: $danger; - border-color: $danger; - - &:hover { - background-color: $danger; - color: white; - } - } - } -} - .no-projects { padding: 60px 40px; text-align: center; @@ -1402,38 +1540,6 @@ export default { } } -.badge { - padding: 4px 10px; - border-radius: 12px; - font-size: 0.75rem; - font-weight: 500; - white-space: nowrap; - - &.badge-primary { - background-color: rgba(0, 114, 206, 0.1); - color: $primary; - border: 1px solid rgba(0, 114, 206, 0.2); - } - - &.badge-success { - background-color: rgba(0, 150, 57, 0.1); - color: $success; - border: 1px solid rgba(0, 150, 57, 0.2); - } - - &.badge-danger { - background-color: rgba(218, 41, 28, 0.1); - color: $danger; - border: 1px solid rgba(218, 41, 28, 0.2); - } - - &.badge-secondary { - background-color: rgba(108, 117, 125, 0.1); - color: #6c757d; - border: 1px solid rgba(108, 117, 125, 0.2); - } -} - // Project Form Section (Full Screen) .project-form-section { background: white; @@ -1694,71 +1800,6 @@ export default { color: var(--color-text); opacity: 0.7; } - - .schema-guide { - margin-top: 12px; - padding: 16px; - background: #f8f9fa; - border: 1px solid #e0e0e0; - border-radius: 8px; - - .form-text { - margin-top: 0; - margin-bottom: 8px; - font-weight: 500; - opacity: 1; - color: var(--color-heading); - } - - .schema-list { - margin: 8px 0 12px 0; - padding-left: 20px; - color: var(--color-text); - font-size: 0.9rem; - line-height: 1.6; - - li { - margin-bottom: 6px; - - code { - background: #e9ecef; - padding: 2px 6px; - border-radius: 4px; - font-size: 0.85em; - color: #d63384; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - } - - ul { - margin-top: 4px; - margin-bottom: 4px; - padding-left: 20px; - } - } - } - - .example-text { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid #e0e0e0; - display: block; - font-size: 0.85rem; - line-height: 1.8; - - code { - display: block; - background: #f1f3f5; - padding: 8px 12px; - border-radius: 6px; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 0.85em; - color: #495057; - margin-top: 4px; - white-space: pre; - overflow-x: auto; - } - } - } } .checkbox-group { @@ -1811,129 +1852,6 @@ export default { gap: 8px; } - // Concept Filtering Styles (from Demo.vue) - .cui-filter-controls { - margin: 0 0 16px 0; - padding: 12px; - background: #f8f9fa; - border: 1px solid #e0e0e0; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - } - - .cui-filter-checkbox { - display: flex; - align-items: center; - gap: 8px; - margin: 0; - font-size: 0.9rem; - cursor: pointer; - font-weight: 400; - color: var(--color-text); - - input[type="checkbox"] { - width: 16px; - height: 16px; - cursor: pointer; - accent-color: $primary; - border: 1px solid #d0d0d0; - } - } - - .cui-filter-paste-toggle { - padding: 4px 8px; - font-size: 0.85rem; - border: 1px solid #d0d0d0; - border-radius: 6px; - background: white; - transition: all 0.2s ease; - - &:hover { - background: #f0f0f0; - border-color: #b0b0b0; - } - } - - .cui-filter-row { - display: flex; - gap: 20px; - align-items: flex-start; - margin: 0 0 16px 0; - } - - .cui-filter-picker { - flex: 0 0 50%; - max-width: 50%; - padding: 12px; - background: #fafafa; - border: 1px solid #e0e0e0; - border-radius: 8px; - } - - .cui-file-picker { - flex: 0 0 calc(50% - 20px); - max-width: calc(50% - 20px); - padding: 12px; - background: #fafafa; - border: 1px solid #e0e0e0; - border-radius: 8px; - - label { - display: block; - margin-bottom: 6px; - font-weight: 500; - color: var(--color-heading); - font-size: 0.9rem; - } - - .form-control { - border: 1px solid #d0d0d0; - } - } - - .cui-pill-row { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin: 6px 0 10px 0; - } - - .cui-pill { - display: inline-flex; - align-items: center; - gap: 8px; - border: 1px solid rgba(0, 0, 0, 0.15); - background: rgba(13, 110, 253, 0.08); - color: #0b5ed7; - border-radius: 999px; - padding: 4px 10px; - font-size: 0.75rem; - line-height: 1; - } - - .cui-pill-text { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - } - - .cui-pill-remove { - border: none; - background: transparent; - color: inherit; - padding: 0; - cursor: pointer; - font-size: 16px; - line-height: 1; - opacity: 0.7; - transition: opacity 0.2s ease; - - &:hover { - opacity: 1; - } - } - .form-actions { display: flex; justify-content: flex-end; @@ -1944,90 +1862,6 @@ export default { flex-shrink: 0; background: white; box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05); - - .btn { - padding: 10px 24px; - font-weight: 500; - border-radius: 8px; - font-size: 0.95rem; - transition: all 0.2s ease; - min-width: 100px; - - &:hover { - transform: translateY(-1px); - } - - &.btn-save { - box-shadow: 0 2px 4px rgba(0, 114, 206, 0.2); - - &:hover { - box-shadow: 0 4px 8px rgba(0, 114, 206, 0.3); - } - } - } - } -} - -.confirm-modal { - :deep(.modal-container) { - width: 500px; - border-radius: 8px; - } - - :deep(.modal-header) { - padding: 20px 24px; - margin: 0; - border-bottom: 1px solid var(--color-border); - - h3 { - color: var(--color-heading); - margin: 0; - } - } - - :deep(.modal-body) { - padding: 24px; - } - - .confirm-content { - .project-name-highlight { - color: $primary; - font-weight: 600; - } - - .warning-text { - padding: 12px; - background-color: rgba(218, 41, 28, 0.1); - border-left: 3px solid $danger; - border-radius: 4px; - margin: 16px 0; - } - - .text-warning { - padding: 12px; - background-color: rgba(118, 134, 146, 0.1); - border-left: 3px solid $warning; - border-radius: 4px; - margin: 16px 0; - } - } - - .form-actions { - display: flex; - justify-content: flex-end; - gap: 12px; - margin-top: 24px; - - .btn { - padding: 10px 20px; - font-weight: 500; - border-radius: 6px; - transition: all 0.2s ease; - - &:hover { - transform: translateY(-1px); - } - } } } @@ -2122,171 +1956,22 @@ export default { padding: 8px 10px; } } - } - - .checkbox-grid { - grid-template-columns: 1fr; - } - .cui-filter-row { - flex-direction: column; - gap: 16px; - - .cui-filter-picker, - .cui-file-picker { - flex: 1 1 100%; - max-width: 100%; + .checkbox-grid { + grid-template-columns: 1fr; } - } -} -// Tab Navigation Styles -.admin-tabs { - display: flex; - gap: 8px; - margin: 20px 0; - border-bottom: 2px solid var(--color-border); - padding-bottom: 0; - flex-shrink: 0; -} - -.project-admin-content { - flex: 1; - min-height: 0; - overflow: hidden; - display: flex; - flex-direction: column; - - .tab-content { - flex: 1; - min-height: 0; - overflow: hidden; - display: flex; - flex-direction: column; - } -} - -.tab-button { - padding: 12px 24px; - background: transparent; - border: none; - border-bottom: 3px solid transparent; - cursor: pointer; - font-size: 0.95rem; - font-weight: 500; - color: var(--color-text-secondary); - display: flex; - align-items: center; - gap: 8px; - transition: all 0.2s ease; - margin-bottom: -2px; - - &:hover { - color: var(--color-primary); - background: rgba(0, 0, 0, 0.02); - } - - &.active { - color: var(--color-primary); - border-bottom-color: var(--color-primary); - font-weight: 600; - } - - svg { - font-size: 1rem; - } -} - -// Admin Section Styles (for Model Packs, Datasets, Users) -.admin-section { - .list-section { - .section-header { - margin-bottom: 20px; - - h3 { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-heading); - margin: 0; - } + .cui-filter-row { + flex-direction: column; + gap: 16px; - .item-count { - font-weight: 400; - color: var(--color-text-secondary); - font-size: 1rem; + .cui-filter-picker, + .cui-file-picker { + flex: 1 1 100%; + max-width: 100%; } } - - .table-container { - background: white; - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - overflow: hidden; - } - } - - .admin-table { - .action-buttons { - display: flex; - gap: 6px; - justify-content: flex-end; - } - } - - .form-section { - background: white; - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - overflow: hidden; - display: flex; - flex-direction: column; - max-height: calc(100vh - 200px); - min-height: auto; - } - - .admin-form { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; - - .form-sections-wrapper { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - min-height: 0; - padding: 20px; - } - - .form-actions { - margin-top: auto; - flex-shrink: 0; - padding: 20px; - border-top: 1px solid var(--color-border); - display: flex; - gap: 12px; - justify-content: flex-end; - background: var(--color-background-light); - } } } -.empty-state { - text-align: center; - padding: 60px 20px; - background: white; - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - - h4 { - font-size: 1.25rem; - color: var(--color-heading); - margin-bottom: 8px; - } - - p { - color: var(--color-text-secondary); - margin-bottom: 20px; - } -}