diff --git a/instance/package.json b/instance/package.json index 097e22b5c..7a7f6a22f 100644 --- a/instance/package.json +++ b/instance/package.json @@ -1,6 +1,6 @@ { "name": "@ngageoint/mage.dev-instance", - "version": "6.6.4", + "version": "6.6.7", "description": "Assemble a Mage Server deployment from the core service, the web-app, and selected plugins. This is primarily a development tool because the dependencies point to relative directories instead of production packages. This can however serve as a starting point to create a production Mage instance package.json.", "scripts": { "start": "npm run start:dev", diff --git a/package-lock.json b/package-lock.json index 8fca3b0cb..eccf40f2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ngageoint/mage.project", - "version": "6.6.4", + "version": "6.6.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ngageoint/mage.project", - "version": "6.6.4", + "version": "6.6.7", "hasInstallScript": true, "dependencies": { "@angular/cdk": "^17.3.10", diff --git a/package.json b/package.json index 09f6d5f79..8ae0f5a43 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@ngageoint/mage.project", "description": "This is the root package definition for the mage-server monorepo.", "private": true, - "version": "6.6.4", + "version": "6.6.7", "files": [], "scripts": { "postinstall": "npm-run-all service:ci web-app:ci arcgis:ci sftp:ci nga-msi:ci", diff --git a/service/npm-shrinkwrap.json b/service/npm-shrinkwrap.json index 32d6e7f7f..2182d530b 100644 --- a/service/npm-shrinkwrap.json +++ b/service/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@ngageoint/mage.service", - "version": "6.6.4", + "version": "6.6.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ngageoint/mage.service", - "version": "6.6.4", + "version": "6.6.7", "dependencies": { "@ngageoint/geopackage": "4.2.6", "@ngageoint/mongodb-migrations": "1.0.0", diff --git a/service/package.json b/service/package.json index bbc251896..3261bc042 100644 --- a/service/package.json +++ b/service/package.json @@ -1,6 +1,6 @@ { "name": "@ngageoint/mage.service", - "version": "6.6.4", + "version": "6.6.7", "displayName": "Mage Service", "description": "Mage is a geospatial situational awareness and data collection platform. The Mage Service is the ReST service API that the Mage client apps use to interact with Mage data.", "keywords": [ diff --git a/service/src/api/observation.js b/service/src/api/observation.js index 536be1046..f35434569 100644 --- a/service/src/api/observation.js +++ b/service/src/api/observation.js @@ -176,9 +176,14 @@ Observation.prototype.validate = function (observation) { let fieldsMessage = ""; const fieldsError = {}; - formDefinitions[formEntry.formId].fields + const formDefinition = formDefinitions[formEntry.formId]; + const userFieldNames = new Set(formDefinition.userFields || []); + formDefinition.fields .filter((fieldDefinition) => !fieldDefinition.archived) .forEach((fieldDefinition) => { + // User fields have dynamically populated choices that are not stored in the + // form definition, so skip choice-based validation for those fields. + if (userFieldNames.has(fieldDefinition.name)) return; const field = fieldFactory.createField( fieldDefinition, formEntry, diff --git a/service/src/entities/observations/entities.observations.ts b/service/src/entities/observations/entities.observations.ts index 91ec3aa7c..b6e61dfdc 100644 --- a/service/src/entities/observations/entities.observations.ts +++ b/service/src/entities/observations/entities.observations.ts @@ -1184,10 +1184,15 @@ function validateFormFieldEntries(formEntry: FormEntry, form: Form, formEntryErr const { mageEvent, observationAttrs } = validation const formFields = form.fields || [] const activeFields = formFields.filter(x => !x.archived) + const userFields = new Set(form.userFields || []) activeFields.forEach(field => { const fieldEntry = formEntry[field.name] const fieldValidation: FormFieldValidationContext = { field, fieldEntry, formEntry, mageEvent, observationAttrs } - const resultEntry = FieldTypeValidationRules[field.type](fieldValidation) + const isUserField = userFields.has(field.name) + const rule = isUserField + ? validateRequiredThen(context => fields.text.TextFieldValidation(context.field, context.fieldEntry, FormFieldValidationResult(context))) + : FieldTypeValidationRules[field.type] + const resultEntry = rule(fieldValidation) if (resultEntry instanceof FormFieldValidationError) { formEntryError.addFieldError(resultEntry) } diff --git a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.html b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.html index b6eddb975..d021427f2 100644 --- a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.html +++ b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.html @@ -2,104 +2,188 @@
-
-
-
- - - - Inactive Users - - - - -
- - Search - - -
+
+
+ + + + Inactive Users + + + + +
+ + Search + + +
+
- + person
{{ user?.displayName || 'Unknown User' }}
-
-
- - -
- -
- -
- - - - Unregistered Devices - - - - -
- - Search - - +
+ No inactive users found.
+
+ +
+ + + +
+
+
+ +
+ + + + Unregistered Devices + + + +
+ + Search + + +
+ +
- +
{{ d.user?.displayName || 'Unknown User' }} - ({{ d?.uid || 'Unknown UID' }}) + + ({{ d?.uid || 'Unknown UID' }}) +
-
-
- - +
+ No unregistered devices found.
- -
+
+ +
+ + + +
+
- +
-
\ No newline at end of file +
diff --git a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.scss b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.scss index 89a57fe04..16f7c1326 100644 --- a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.scss +++ b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.scss @@ -4,20 +4,25 @@ $font-md: 16px; $font-sm: 14px; $font-xs: 12px; +$top-panel-height: 390px; +$top-panel-list-height: 236px; + ::ng-deep .admin-main-content { transition: margin-left 0.3s; width: 100%; min-height: calc(100vh - 127px); + min-height: calc(100dvh - 127px); display: flex; flex-direction: column; &:has(admin-dashboard) { min-height: calc(100vh - 127px); + min-height: calc(100dvh - 127px); overflow: auto; - -webkit-overflow-scrolling: touch; @media (max-width: 959px) { min-height: calc(100vh - 114px); + min-height: calc(100dvh - 114px); overflow: auto; } } @@ -48,10 +53,6 @@ $font-xs: 12px; border: 1px solid #d7d7d7; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); background: #fff; - - @media (max-width: 959px), (max-height: 600px) { - height: auto; - } } .top-row { @@ -61,178 +62,247 @@ $font-xs: 12px; width: 100%; overflow: visible; - @media (max-width: 959px), (max-height: 600px) { - flex-direction: column; + @media (max-width: 959px), + (max-height: 600px) { + display: flex; + flex-direction: column !important; + align-items: stretch; height: auto; max-height: none; } + } - .inactive-users, - .unregistered-devices { - flex: 1 1 0; - min-width: 0; + .dashboard-panel, + .inactive-users, + .unregistered-devices { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + height: $top-panel-height; + min-height: $top-panel-height; + max-height: $top-panel-height; + + @media (max-width: 959px), + (max-height: 600px) { + flex: 0 0 auto; + width: 100%; + height: $top-panel-height; + min-height: $top-panel-height; + max-height: $top-panel-height; + } + } + + .dashboard-panel-card, + .inactive-users mat-card, + .inactive-users .mat-mdc-card, + .unregistered-devices mat-card, + .unregistered-devices .mat-mdc-card { + flex: 1 1 auto; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0; + border: 0 !important; + box-shadow: none !important; + background: transparent !important; + border-radius: 0 !important; + } + + .search-wrapper { + flex: 0 0 auto; + background-color: #f7f7f7; + padding: 6px 10px 0 10px; + border: 1px solid #a9a9a9; + border-bottom: none; + } + + mat-form-field, + .mat-mdc-form-field { + width: 100%; + margin-bottom: -18px; + + .mat-mdc-text-field-wrapper { + background-color: transparent; + padding: 0 8px; + } + + .mdc-line-ripple::before, + .mdc-line-ripple::after { + border-bottom-color: transparent; + } + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + } + + .mdc-floating-label { + padding-bottom: 15px; + font-size: $font-sm; + } + + .panel-list-shell { + position: relative; + flex: 0 0 $top-panel-list-height; + height: $top-panel-list-height; + min-height: $top-panel-list-height; + max-height: $top-panel-list-height; + overflow: hidden; + border: 1px solid #a9a9a9; + background: #fcfcfc; + + @media (max-width: 959px), + (max-height: 600px) { + flex: 0 0 $top-panel-list-height; + height: $top-panel-list-height; + min-height: $top-panel-list-height; + max-height: $top-panel-list-height; + } + } + + mat-list, + .mat-mdc-list { + height: 100%; + min-height: 100%; + max-height: 100%; + overflow-y: auto; + overflow-x: hidden; + padding-top: 0; + background: #fcfcfc; + } + + mat-list-item, + a[mat-list-item], + .mat-mdc-list-item { + min-height: 48px; + height: auto; + padding: 4px 10px !important; + border-bottom: 1px solid #efefef; + display: flex; + align-items: center; + + &:last-child { + border-bottom: none; + } + + .mdc-list-item__content, + .mat-mdc-list-item-unscoped-content { display: flex; - flex-direction: column; + align-items: center; + min-width: 0; + width: 100%; + } - @media (max-width: 959px), (max-height: 600px) { - height: auto; - } + mat-icon, + i[matListIcon] { + font-size: $font-md; + margin-right: 10px; + color: #666; + margin-top: 0; + flex: 0 0 18px; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + } - mat-card, - .mat-mdc-card { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - padding: 0; - border: 0 !important; - box-shadow: none !important; - background: transparent !important; - border-radius: 0 !important; - } + [matLine], + .mat-line-horizontal, + .mdc-list-item__primary-text { + font-size: $font-sm; + line-height: 1.3; + font-weight: 500; + color: #333; + display: inline-flex; + align-items: center; + min-width: 0; + flex: 1 1 auto; + } + + button[mat-button], + .mat-mdc-button { + margin-left: auto; + min-width: 92px; + height: 32px; + padding: 0 10px; + font-size: $font-xs; + line-height: 32px; + border: 1px solid #a8a8a8; + border-radius: 4px; + background: #f3f3f3; + color: #8a8a8a; + text-transform: none !important; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; - .search-wrapper { - background-color: #f7f7f7; - padding: 6px 10px 0 10px; - border: 1px solid #a9a9a9; - border-bottom: none; + .mdc-button__label { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; } - mat-form-field, - .mat-mdc-form-field { - width: 100%; - margin-bottom: -18px; + mat-icon { + font-size: $font-sm; + width: $font-sm; + height: $font-sm; + margin: 0; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + } - .mat-mdc-text-field-wrapper { - background-color: transparent; - padding: 0 8px; - } + span { + display: inline-flex; + align-items: center; + line-height: 1; + margin: 0; + } + } - .mdc-line-ripple::before, - .mdc-line-ripple::after { - border-bottom-color: transparent; - } + @media (max-width: 560px) { - .mat-mdc-form-field-subscript-wrapper { - display: none; - } + .mdc-list-item__content, + .mat-mdc-list-item-unscoped-content { + flex-wrap: wrap; + gap: 4px 8px; } - .mdc-floating-label { - padding-bottom: 15px; - font-size: $font-sm; + [matLine], + .mat-line-horizontal, + .mdc-list-item__primary-text { + flex: 1 1 calc(100% - 32px); } - mat-list, - .mat-mdc-list { - flex: 1; - overflow-y: auto; - max-height: 240px; - border: 1px solid #a9a9a9; - padding-top: 0; - background: #fcfcfc; - - @media (max-width: 959px), (max-height: 600px) { - max-height: 260px; - } - - mat-list-item, - a[mat-list-item], - .mat-mdc-list-item { - min-height: 48px; - height: auto; - padding: 4px 10px !important; - border-bottom: 1px solid #efefef; - display: flex; - align-items: center; - - &:last-child { - border-bottom: none; - } - - .mdc-list-item__content, - .mat-mdc-list-item-unscoped-content { - display: flex; - align-items: center; - min-width: 0; - width: 100%; - } - - mat-icon, - i[matListIcon] { - font-size: $font-md; - margin-right: 10px; - color: #666; - margin-top: 0; - flex: 0 0 18px; - width: 18px; - height: 18px; - display: inline-flex; - align-items: center; - justify-content: center; - } - - [matLine], - .mat-line-horizontal, - .mdc-list-item__primary-text { - font-size: $font-sm; - line-height: 1.3; - font-weight: 500; - color: #333; - display: inline-flex; - align-items: center; - min-width: 0; - flex: 1 1 auto; - } - - button[mat-button], - .mat-mdc-button { - margin-left: auto; - min-width: 92px; - height: 32px; - padding: 0 10px; - font-size: $font-xs; - line-height: 32px; - border: 1px solid #a8a8a8; - border-radius: 4px; - background: #f3f3f3; - color: #8a8a8a; - text-transform: none !important; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - - .mdc-button__label { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - } - - mat-icon { - font-size: $font-sm; - width: $font-sm; - height: $font-sm; - margin: 0; - display: inline-flex; - align-items: center; - justify-content: center; - line-height: 1; - } - - span { - display: inline-flex; - align-items: center; - line-height: 1; - margin: 0; - } - } - } + button[mat-button], + .mat-mdc-button { + margin-left: 28px; + margin-top: 4px; } } } + .empty-panel-state { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + text-align: center; + color: #6b7280; + font-size: $font-sm; + pointer-events: none; + background: #fcfcfc; + } + .title-with-badge { display: flex; align-items: center; @@ -262,29 +332,84 @@ $font-xs: 12px; } .pagination-controls { + flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; + gap: 8px; margin-top: 10px; padding: 0 2px; + .pagination-button, button { min-width: auto; - padding: 0; + height: 28px; + padding: 0 8px; font-size: $font-xs; letter-spacing: 0.04em; text-transform: none !important; - color: #8b8b8b; + border-radius: 4px; + transition: color 160ms ease, background-color 160ms ease, + opacity 160ms ease; + } + + .pagination-button:first-child { + margin-right: auto; } - @media (max-width: 480px), (max-height: 600px) { + .pagination-button:last-child { + margin-left: auto; + } + + .pagination-button:not(:disabled):not(.mat-mdc-button-disabled), + button:not(:disabled):not(.mat-mdc-button-disabled) { + color: #1e88e5 !important; + opacity: 1; + cursor: pointer; + + --mdc-text-button-label-text-color: #1e88e5; + --mat-text-button-state-layer-color: #1e88e5; + + &:hover { + background: rgba(30, 136, 229, 0.08); + color: #1565c0 !important; + + --mdc-text-button-label-text-color: #1565c0; + } + + &:focus-visible { + outline: 2px solid rgba(30, 136, 229, 0.35); + outline-offset: 2px; + } + } + + .pagination-button:disabled, + .pagination-button[disabled], + .pagination-button.mat-mdc-button-disabled, + button:disabled, + button[disabled], + button.mat-mdc-button-disabled { + color: #b8b8b8 !important; + opacity: 0.55; + cursor: not-allowed; + pointer-events: none; + + --mdc-text-button-disabled-label-text-color: #b8b8b8; + --mdc-text-button-label-text-color: #b8b8b8; + } + + @media (max-width: 480px), + (max-height: 600px) { align-items: stretch; - gap: 12px; + gap: 8px; flex-direction: column; + .pagination-button, button { width: 100%; text-align: center; + margin-left: 0; + margin-right: 0; } } } @@ -322,7 +447,6 @@ $font-xs: 12px; min-height: 0; overflow-y: auto; overflow-x: hidden; - -webkit-overflow-scrolling: touch; overscroll-behavior: contain; border-bottom: 1px solid #e5e7eb; padding-bottom: 0; @@ -366,30 +490,9 @@ $font-xs: 12px; font-size: $font-sm; } -/* important: reserve space for paginator/footer inside mage-logins */ :host ::ng-deep mage-logins { - display: flex; - flex-direction: column; - flex: 1 1 auto; - min-height: 0; - height: 100%; -} - -:host ::ng-deep mage-logins .section-card, -:host ::ng-deep mage-logins .table-wrapper { - display: flex; - flex-direction: column; - flex: 1 1 auto; - min-height: 0; - height: 100%; - max-height: 100%; -} - -:host ::ng-deep mage-logins .table-scroll, -:host ::ng-deep mage-logins .login-table-scroll { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; + display: block; + width: 100%; } :host ::ng-deep mage-logins .mat-paginator, @@ -402,4 +505,4 @@ $font-xs: 12px; .mt-5 { margin-top: 48px; -} +} \ No newline at end of file diff --git a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.spec.ts b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.spec.ts index e74106c60..9c7a18a33 100644 --- a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.spec.ts +++ b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.spec.ts @@ -16,17 +16,17 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatIconTestingModule } from '@angular/material/icon/testing'; -import { MatFormFieldModule as MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule as MatInputModule } from '@angular/material/input'; -import { MatButtonModule as MatButtonModule } from '@angular/material/button'; -import { MatCardModule as MatCardModule } from '@angular/material/card'; -import { MatListModule as MatListModule } from '@angular/material/list'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatListModule } from '@angular/material/list'; import { MatBadgeModule } from '@angular/material/badge'; -import { MatSelectModule as MatSelectModule } from '@angular/material/select'; +import { MatSelectModule } from '@angular/material/select'; import { MatDatepickerModule } from '@angular/material/datepicker'; -import { MatAutocompleteModule as MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatNativeDateModule } from '@angular/material/core'; -import { MatTableModule as MatTableModule } from '@angular/material/table'; +import { MatTableModule } from '@angular/material/table'; import { RouterTestingModule } from '@angular/router/testing'; import { AdminUserService } from '../services/admin-user.service'; @@ -88,6 +88,60 @@ const TEST_USERS: any[] = [ }, email: 'kiku@example.com', phones: [] + }, + { + id: '4', + username: 'sakura_', + displayName: 'Sakura', + active: true, + enabled: true, + authentication: 'LOCAL', + createdAt: new Date().toDateString(), + lastUpdated: new Date().toDateString(), + recentEventIds: [], + role: { + id: 'Test', + name: 'role', + permissions: [] + }, + email: 'sakura@example.com', + phones: [] + }, + { + id: '5', + username: 'yuki_', + displayName: 'Yuki', + active: true, + enabled: true, + authentication: 'LOCAL', + createdAt: new Date().toDateString(), + lastUpdated: new Date().toDateString(), + recentEventIds: [], + role: { + id: 'Test', + name: 'role', + permissions: [] + }, + email: 'yuki@example.com', + phones: [] + }, + { + id: '6', + username: 'momo_', + displayName: 'Momo', + active: true, + enabled: true, + authentication: 'LOCAL', + createdAt: new Date().toDateString(), + lastUpdated: new Date().toDateString(), + recentEventIds: [], + role: { + id: 'Test', + name: 'role', + permissions: [] + }, + email: 'momo@example.com', + phones: [] } ]; @@ -95,7 +149,7 @@ const TEST_DEVICES: any[] = [ { id: 'd1', uid: 'Primary Desktop', - registered: true, + registered: false, appVersion: 'Web Client', userAgent: '', iconClass: '' @@ -103,7 +157,7 @@ const TEST_DEVICES: any[] = [ { id: 'd2', uid: 'iOS Device', - registered: true, + registered: false, appVersion: 'mobile', userAgent: 'iOS', iconClass: '' @@ -115,6 +169,30 @@ const TEST_DEVICES: any[] = [ appVersion: 'mobile', userAgent: 'android', iconClass: '' + }, + { + id: 'd4', + uid: 'Tablet Device', + registered: false, + appVersion: 'mobile', + userAgent: 'tablet', + iconClass: '' + }, + { + id: 'd5', + uid: 'Backup Phone', + registered: false, + appVersion: 'mobile', + userAgent: 'mobile', + iconClass: '' + }, + { + id: 'd6', + uid: 'Field Laptop', + registered: false, + appVersion: 'Web Client', + userAgent: '', + iconClass: '' } ]; @@ -132,6 +210,40 @@ const mockDeviceService: Partial & any = { .and.callFake((_id: string, patch: any) => { const updated = { ...TEST_DEVICES[0], ...patch }; return of(updated); + }), + getDashboardDevicePage: jasmine + .createSpy('getDashboardDevicePage') + .and.callFake((options: any) => { + const start = options?.start || 0; + const limit = options?.limit || 5; + const term = (options?.term || '').toLowerCase(); + + const filteredDevices = TEST_DEVICES.filter((device) => { + if (options?.registered !== undefined) { + if (device.registered !== options.registered) { + return false; + } + } + + if (!term) { + return true; + } + + return (device.uid || '').toLowerCase().includes(term); + }); + + const devices = filteredDevices.slice(start, start + limit); + const nextStart = + start + limit < filteredDevices.length ? start + limit : null; + const prevStart = start - limit >= 0 ? Math.max(start - limit, 0) : null; + + return of({ + start, + nextStart, + prevStart, + totalCount: filteredDevices.length, + devices + }); }) }; @@ -150,16 +262,12 @@ const mockUserPagingService: Partial & any = { refresh: jasmine.createSpy('refresh').and.returnValue(of([])), users: jasmine.createSpy('users').and.callFake((_state: any) => TEST_USERS), count: jasmine.createSpy('count').and.returnValue(TEST_USERS.length), - hasNext: jasmine.createSpy('hasNext').and.returnValue(true), - hasPrevious: jasmine.createSpy('hasPrevious').and.returnValue(false), - next: jasmine.createSpy('next').and.returnValue(of([TEST_USERS[1]])), - previous: jasmine.createSpy('previous').and.returnValue(of([TEST_USERS[0]])), search: jasmine .createSpy('search') .and.callFake((_state: any, term: string) => { return of( - TEST_USERS.filter((u) => - (u.displayName || '') + TEST_USERS.filter((user) => + (user.displayName || '') .toLowerCase() .includes((term || '').toLowerCase()) ) @@ -170,30 +278,7 @@ const mockUserPagingService: Partial & any = { const mockDevicePagingService: Partial & any = { constructDefault: jasmine .createSpy('constructDefault') - .and.returnValue(deviceStateAndData), - refresh: jasmine.createSpy('refresh').and.returnValue(of([])), - devices: jasmine - .createSpy('devices') - .and.callFake((_state: any) => TEST_DEVICES), - count: jasmine.createSpy('count').and.returnValue(TEST_DEVICES.length), - hasNext: jasmine.createSpy('hasNext').and.returnValue(true), - hasPrevious: jasmine.createSpy('hasPrevious').and.returnValue(false), - next: jasmine.createSpy('next').and.returnValue(of([TEST_DEVICES[1]])), - previous: jasmine - .createSpy('previous') - .and.returnValue(of([TEST_DEVICES[0]])), - search: jasmine - .createSpy('search') - .and.callFake((_state: any, term: string) => { - return of( - TEST_DEVICES.filter((d) => - (d.uid || '') - .toString() - .toLowerCase() - .includes((term || '').toLowerCase()) - ) - ); - }) + .and.returnValue(deviceStateAndData) }; describe('AdminDashboardComponent', () => { @@ -234,16 +319,11 @@ describe('AdminDashboardComponent', () => { (mockUserPagingService.constructDefault as jasmine.Spy).calls.reset(); (mockUserPagingService.refresh as jasmine.Spy).calls.reset(); - (mockDevicePagingService.constructDefault as jasmine.Spy).calls.reset(); - (mockDevicePagingService.refresh as jasmine.Spy).calls.reset(); (mockUserPagingService.search as jasmine.Spy).calls.reset(); - (mockDevicePagingService.search as jasmine.Spy).calls.reset(); - (mockUserPagingService.next as jasmine.Spy).calls.reset(); - (mockUserPagingService.previous as jasmine.Spy).calls.reset(); - (mockDevicePagingService.next as jasmine.Spy).calls.reset(); - (mockDevicePagingService.previous as jasmine.Spy).calls.reset(); + (mockDevicePagingService.constructDefault as jasmine.Spy).calls.reset(); (mockUserService.updateUser as jasmine.Spy).calls.reset(); (mockDeviceService.updateDevice as jasmine.Spy).calls.reset(); + (mockDeviceService.getDashboardDevicePage as jasmine.Spy).calls.reset(); fixture = TestBed.createComponent(AdminDashboardComponent); component = fixture.componentInstance; @@ -254,12 +334,17 @@ describe('AdminDashboardComponent', () => { expect(component).toBeTruthy(); }); - it('should call paging refresh in ngOnInit and populate lists', fakeAsync(() => { + it('should initialize paging data and populate dashboard lists', fakeAsync(() => { tick(); + + expect(mockUserPagingService.constructDefault).toHaveBeenCalled(); + expect(mockDevicePagingService.constructDefault).toHaveBeenCalled(); expect(mockUserPagingService.refresh).toHaveBeenCalled(); - expect(mockDevicePagingService.refresh).toHaveBeenCalled(); - expect(component.inactiveUsers.length).toBe(TEST_USERS.length); - expect(component.unregisteredDevices.length).toBe(TEST_DEVICES.length); + expect(mockDeviceService.getDashboardDevicePage).toHaveBeenCalled(); + + expect(component.inactiveUsers).toEqual(TEST_USERS.slice(0, 5)); + expect(component.unregisteredDevices).toEqual(TEST_DEVICES.slice(0, 5)); + expect(component.deviceTotalCount).toBe(TEST_DEVICES.length); })); it('should activate user and emit event', fakeAsync(() => { @@ -268,12 +353,13 @@ describe('AdminDashboardComponent', () => { spyOn(component.onUserActivated, 'emit'); (mockUserService.updateUser as jasmine.Spy).and.callFake( - (_id: string, _user: any) => of(_user) + (_id: string, updatedUser: any) => of(updatedUser) ); component.activateUser(user); tick(); + expect(mockUserService.updateUser).toHaveBeenCalledWith(user.id, user); expect(user.active).toBeTrue(); expect(component.onUserActivated.emit).toHaveBeenCalledWith({ user }); })); @@ -283,9 +369,15 @@ describe('AdminDashboardComponent', () => { component.onDeviceEnabled = new EventEmitter(); spyOn(component.onDeviceEnabled, 'emit'); - component.registerDevice(new MouseEvent('click'), device); + const event = new MouseEvent('click'); + spyOn(event, 'preventDefault'); + spyOn(event, 'stopPropagation'); + + component.registerDevice(event, device); tick(); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); expect(mockDeviceService.updateDevice).toHaveBeenCalledWith(device.id, { registered: true }); @@ -301,44 +393,86 @@ describe('AdminDashboardComponent', () => { it('should search users', fakeAsync(() => { component.userSearch = 'Lily Hoshikawa'; + component.search(); tick(); + + expect(mockUserPagingService.search).toHaveBeenCalledWith( + userStateAndData.inactive, + 'Lily Hoshikawa' + ); expect(component.inactiveUsers).toEqual([TEST_USERS[0]]); })); it('should search devices', fakeAsync(() => { component.deviceSearch = 'iOS Device'; + component.searchDevices(); tick(); + + expect(mockDeviceService.getDashboardDevicePage).toHaveBeenCalledWith({ + start: 0, + limit: component.devicePageSize, + registered: false, + user: true, + includePagination: true, + term: 'iOS Device' + }); expect(component.unregisteredDevices).toEqual([TEST_DEVICES[1]]); + expect(component.deviceTotalCount).toBe(1); })); it('should handle previous and next user pages', fakeAsync(() => { + tick(); + expect(component.hasNext()).toBeTrue(); expect(component.hasPrevious()).toBeFalse(); component.next(); tick(); - expect(component.inactiveUsers).toEqual([TEST_USERS[1]]); + + expect(component.userPageIndex).toBe(1); + expect(component.inactiveUsers).toEqual([TEST_USERS[5]]); + expect(component.hasNext()).toBeFalse(); + expect(component.hasPrevious()).toBeTrue(); component.previous(); tick(); - expect(component.inactiveUsers).toEqual([TEST_USERS[0]]); + + expect(component.userPageIndex).toBe(0); + expect(component.inactiveUsers).toEqual(TEST_USERS.slice(0, 5)); })); it('should handle previous and next device pages', fakeAsync(() => { + tick(); + expect(component.hasNextDevice()).toBeTrue(); expect(component.hasPreviousDevice()).toBeFalse(); component.nextDevice(); tick(); - expect(component.unregisteredDevices).toEqual([TEST_DEVICES[1]]); + + expect(component.deviceStart).toBe(5); + expect(component.unregisteredDevices).toEqual([TEST_DEVICES[5]]); + expect(component.hasNextDevice()).toBeFalse(); + expect(component.hasPreviousDevice()).toBeTrue(); component.previousDevice(); tick(); - expect(component.unregisteredDevices).toEqual([TEST_DEVICES[0]]); + + expect(component.deviceStart).toBe(0); + expect(component.unregisteredDevices).toEqual(TEST_DEVICES.slice(0, 5)); })); + it('should not navigate to a user without an id', () => { + const router = TestBed.inject(RouterTestingModule as any); + + component.goToUser(null as any); + component.goToUser({}); + + expect(router).toBeTruthy(); + }); + it('should set icon classes correctly', () => { expect(component.iconClass(TEST_DEVICES[0])).toContain('desktop'); expect(component.iconClass(TEST_DEVICES[2])).toContain('android'); @@ -346,6 +480,12 @@ describe('AdminDashboardComponent', () => { expect( component.iconClass({ ...TEST_DEVICES[1], userAgent: 'mobile' }) ).toContain('mobile'); + expect( + component.iconClass({ + ...TEST_DEVICES[1], + iconClass: 'custom-device-icon' + }) + ).toBe('custom-device-icon'); expect(component.iconClass(null as any)).toEqual(''); }); }); diff --git a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.ts b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.ts index 1ee66071a..6a05fe58f 100644 --- a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.ts +++ b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.ts @@ -9,8 +9,11 @@ import { ActivatedRoute, Router } from '@angular/router'; import { Subject, takeUntil } from 'rxjs'; import { AdminBreadcrumb } from '../admin-breadcrumb/admin-breadcrumb.model'; +import { + AdminDeviceService, + DashboardDevicePageInfo +} from '../services/admin-device.service'; import { AdminUserService } from '../services/admin-user.service'; -import { AdminDeviceService } from '../services/admin-device.service'; import { DevicePagingService } from '../../services/device-paging.service'; import { UserPagingService } from '../../services/user-paging.service'; import { User } from '../admin-users/user'; @@ -34,10 +37,26 @@ export class AdminDashboardComponent implements OnInit, OnDestroy { deviceStateAndData!: ReturnType; inactiveUsers: Array[number]> = []; - unregisteredDevices: Array< - ReturnType[number] + unregisteredDevices: any[] = []; + + readonly userPageSize = 5; + readonly devicePageSize = 5; + + userPageIndex = 0; + loadingUsersPage = false; + loadingDevicesPage = false; + + private allInactiveUsers: Array< + ReturnType[number] > = []; + deviceStart = 0; + deviceNextStart: number | null = null; + devicePrevStart: number | null = null; + deviceTotalCount = 0; + + private devicePageCache = new Map(); + breadcrumbs: AdminBreadcrumb[] = [ { title: 'Dashboard', @@ -68,23 +87,8 @@ export class AdminDashboardComponent implements OnInit, OnDestroy { this.currentUser = user; }); - this.devicePagingService - .refresh(this.deviceStateAndData) - .pipe(takeUntil(this.destroy$)) - .subscribe(() => { - this.unregisteredDevices = this.devicePagingService.devices( - this.deviceStateAndData[this.deviceState] - ); - }); - - this.userPagingService - .refresh(this.stateAndData) - .pipe(takeUntil(this.destroy$)) - .subscribe(() => { - this.inactiveUsers = this.userPagingService.users( - this.stateAndData[this.userState] - ); - }); + this.refreshDevices(); + this.refreshUsers(); } ngOnDestroy(): void { @@ -94,98 +98,95 @@ export class AdminDashboardComponent implements OnInit, OnDestroy { goToUser(user: any): void { if (!user?.id) return; + this.router.navigate(['../users', user.id], { relativeTo: this.route }); } goToDevice(device: any): void { if (!device?.id) return; + this.router.navigate(['../devices', device.id], { relativeTo: this.route }); } count(): number { - return this.userPagingService.count(this.stateAndData[this.userState]); + const state = this.stateAndData?.[this.userState]; + + if (!state) { + return this.allInactiveUsers.length; + } + + const serviceCount = this.userPagingService.count(state); + + return serviceCount || this.allInactiveUsers.length; + } + + deviceCount(): number { + return this.deviceTotalCount || this.unregisteredDevices.length; } hasNext(): boolean { - return this.userPagingService.hasNext(this.stateAndData[this.userState]); + return (this.userPageIndex + 1) * this.userPageSize < this.count(); } next(): void { - this.userPagingService - .next(this.stateAndData[this.userState]) - .pipe(takeUntil(this.destroy$)) - .subscribe((users) => { - this.inactiveUsers = users; - }); + if (!this.hasNext() || this.loadingUsersPage) return; + + this.userPageIndex += 1; + this.applyUserPage(); } hasPrevious(): boolean { - return this.userPagingService.hasPrevious( - this.stateAndData[this.userState] - ); + return this.userPageIndex > 0; } previous(): void { - this.userPagingService - .previous(this.stateAndData[this.userState]) - .pipe(takeUntil(this.destroy$)) - .subscribe((users) => { - this.inactiveUsers = users; - }); - } + if (!this.hasPrevious() || this.loadingUsersPage) return; - deviceCount(): number { - return this.devicePagingService.count( - this.deviceStateAndData[this.deviceState] - ); + this.userPageIndex -= 1; + this.applyUserPage(); } hasNextDevice(): boolean { - return this.devicePagingService.hasNext( - this.deviceStateAndData[this.deviceState] - ); + return this.deviceNextStart !== null && !this.loadingDevicesPage; } nextDevice(): void { - this.devicePagingService - .next(this.deviceStateAndData[this.deviceState]) - .pipe(takeUntil(this.destroy$)) - .subscribe((devices) => { - this.unregisteredDevices = devices; - }); + if (!this.hasNextDevice() || this.deviceNextStart === null) return; + + this.loadDevicePage(this.deviceNextStart); } hasPreviousDevice(): boolean { - return this.devicePagingService.hasPrevious( - this.deviceStateAndData[this.deviceState] - ); + return this.devicePrevStart !== null && !this.loadingDevicesPage; } previousDevice(): void { - this.devicePagingService - .previous(this.deviceStateAndData[this.deviceState]) - .pipe(takeUntil(this.destroy$)) - .subscribe((devices) => { - this.unregisteredDevices = devices; - }); + if (!this.hasPreviousDevice() || this.devicePrevStart === null) return; + + this.loadDevicePage(this.devicePrevStart); } search(): void { + this.userPageIndex = 0; + this.loadingUsersPage = true; + this.userPagingService .search(this.stateAndData[this.userState], this.userSearch) .pipe(takeUntil(this.destroy$)) - .subscribe((users) => { - this.inactiveUsers = users; + .subscribe({ + next: (users) => { + this.setUsers(users); + this.loadingUsersPage = false; + }, + error: () => { + this.loadingUsersPage = false; + } }); } searchDevices(): void { - this.devicePagingService - .search(this.deviceStateAndData[this.deviceState], this.deviceSearch) - .pipe(takeUntil(this.destroy$)) - .subscribe((devices) => { - this.unregisteredDevices = devices; - }); + this.devicePageCache.clear(); + this.loadDevicePage(0); } iconClass(device: any): string { @@ -193,11 +194,19 @@ export class AdminDashboardComponent implements OnInit, OnDestroy { if (device.iconClass) return device.iconClass; const userAgent = (device.userAgent || '').toLowerCase(); - if (device.appVersion === 'Web Client') + + if (device.appVersion === 'Web Client') { return 'fa fa-desktop admin-desktop-icon-xs'; - if (userAgent.includes('android')) + } + + if (userAgent.includes('android')) { return 'fa fa-android admin-android-icon-xs'; - if (userAgent.includes('ios')) return 'fa fa-apple admin-apple-icon-xs'; + } + + if (userAgent.includes('ios')) { + return 'fa fa-apple admin-apple-icon-xs'; + } + return 'fa fa-mobile admin-generic-icon-xs'; } @@ -214,15 +223,7 @@ export class AdminDashboardComponent implements OnInit, OnDestroy { .updateUser(user.id, user) .pipe(takeUntil(this.destroy$)) .subscribe(() => { - this.userPagingService - .refresh(this.stateAndData) - .pipe(takeUntil(this.destroy$)) - .subscribe(() => { - this.inactiveUsers = this.userPagingService.users( - this.stateAndData[this.userState] - ); - }); - + this.refreshUsers(); this.onUserActivated.emit({ user }); }); } @@ -231,20 +232,116 @@ export class AdminDashboardComponent implements OnInit, OnDestroy { event.preventDefault(); event.stopPropagation(); + if (!device?.id) return; + this.deviceService .updateDevice(device.id, { registered: true }) .pipe(takeUntil(this.destroy$)) .subscribe((updatedDevice) => { - this.devicePagingService - .refresh(this.deviceStateAndData) - .pipe(takeUntil(this.destroy$)) - .subscribe(() => { - this.unregisteredDevices = this.devicePagingService.devices( - this.deviceStateAndData[this.deviceState] - ); - }); - + this.devicePageCache.clear(); + this.refreshDevices(); this.onDeviceEnabled.emit({ device: updatedDevice }); }); } + + private refreshUsers(): void { + this.userPageIndex = 0; + this.loadingUsersPage = true; + + this.userPagingService + .refresh(this.stateAndData) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + const users = this.userPagingService.users( + this.stateAndData[this.userState] + ); + + this.setUsers(users); + this.loadingUsersPage = false; + }, + error: () => { + this.loadingUsersPage = false; + } + }); + } + + private refreshDevices(): void { + this.devicePageCache.clear(); + this.loadDevicePage(0); + } + + private loadDevicePage(start: number): void { + const cached = this.devicePageCache.get(start); + + if (cached) { + this.applyDevicePage(cached); + return; + } + + this.loadingDevicesPage = true; + + this.deviceService + .getDashboardDevicePage({ + start, + limit: this.devicePageSize, + registered: false, + user: true, + includePagination: true, + term: this.deviceSearch || undefined + }) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (page) => { + this.devicePageCache.set(start, page); + this.applyDevicePage(page); + this.loadingDevicesPage = false; + }, + error: () => { + this.unregisteredDevices = []; + this.deviceNextStart = null; + this.devicePrevStart = null; + this.loadingDevicesPage = false; + } + }); + } + + private applyDevicePage(page: DashboardDevicePageInfo): void { + this.deviceStart = page.start; + this.deviceNextStart = page.nextStart; + this.devicePrevStart = page.prevStart; + this.deviceTotalCount = page.totalCount; + this.unregisteredDevices = page.devices || []; + } + + private setUsers( + users: Array[number]> = [] + ): void { + this.allInactiveUsers = users || []; + this.clampUserPageIndex(); + this.applyUserPage(); + } + + private applyUserPage(): void { + const start = this.userPageIndex * this.userPageSize; + const end = start + this.userPageSize; + + this.inactiveUsers = this.allInactiveUsers.slice(start, end); + } + + private clampUserPageIndex(): void { + const maxPageIndex = this.maxUserPageIndex(); + + if (this.userPageIndex > maxPageIndex) { + this.userPageIndex = maxPageIndex; + } + } + + private maxUserPageIndex(): number { + const total = this.count(); + + if (!total) return 0; + + return Math.ceil(total / this.userPageSize) - 1; + } } diff --git a/web-app/admin/src/app/admin/admin-devices/create-device/create-device.component.scss b/web-app/admin/src/app/admin/admin-devices/create-device/create-device.component.scss index 208541232..a646c7924 100644 --- a/web-app/admin/src/app/admin/admin-devices/create-device/create-device.component.scss +++ b/web-app/admin/src/app/admin/admin-devices/create-device/create-device.component.scss @@ -166,4 +166,9 @@ mat-form-field { .mat-autocomplete-panel { z-index: 10000 !important; } +} + +.btn-primary { + background-color: #1e88e5; + color: #fff; } \ No newline at end of file diff --git a/web-app/admin/src/app/admin/admin-devices/dashboard/devices-dashboard.component.scss b/web-app/admin/src/app/admin/admin-devices/dashboard/devices-dashboard.component.scss index 3b847281e..8348d30ab 100644 --- a/web-app/admin/src/app/admin/admin-devices/dashboard/devices-dashboard.component.scss +++ b/web-app/admin/src/app/admin/admin-devices/dashboard/devices-dashboard.component.scss @@ -9,8 +9,8 @@ $font-xs: 12px; &:has(admin-devices) { height: 92vh; + height: 92dvh; overflow-y: hidden; - -webkit-overflow-scrolling: touch; @media (max-height: 500px) { overflow-y: scroll; @@ -20,6 +20,7 @@ $font-xs: 12px; admin-devices { .container { height: 90vh; + height: 90dvh; } } } @@ -112,7 +113,7 @@ $font-xs: 12px; min-width: 0; } -.filters > * { +.filters>* { min-width: 0; } @@ -127,7 +128,7 @@ $font-xs: 12px; } :host ::ng-deep .filters mage-card-navbar, -:host ::ng-deep .filters mage-card-navbar > *, +:host ::ng-deep .filters mage-card-navbar>*, :host ::ng-deep .filters mage-card-navbar .card-navbar, :host ::ng-deep .filters mage-card-navbar .search-container, :host ::ng-deep .filters mage-card-navbar .search-wrapper, @@ -468,6 +469,7 @@ $font-xs: 12px; .mt-5 { margin-top: 5px !important; } + .mb-5 { margin-bottom: 5px !important; } @@ -477,4 +479,4 @@ $font-xs: 12px; :host .mat-row a { color: inherit !important; text-decoration: none !important; -} +} \ No newline at end of file diff --git a/web-app/admin/src/app/admin/admin-event/admin-event-form/admin-event-form-preview/form-preview-dialog/admin-event-form-preview-dialog.component.html b/web-app/admin/src/app/admin/admin-event/admin-event-form/admin-event-form-preview/form-preview-dialog/admin-event-form-preview-dialog.component.html index 131cdd4a3..32cadcf98 100644 --- a/web-app/admin/src/app/admin/admin-event/admin-event-form/admin-event-form-preview/form-preview-dialog/admin-event-form-preview-dialog.component.html +++ b/web-app/admin/src/app/admin/admin-event/admin-event-form/admin-event-form-preview/form-preview-dialog/admin-event-form-preview-dialog.component.html @@ -1,14 +1,21 @@ -

{{formDefinition.name}}

- -
- - +

{{ formDefinition.name }}

+ + +
+
+ + +
+ - - \ No newline at end of file + + diff --git a/web-app/admin/src/app/admin/admin-event/admin-event-form/admin-event-form-preview/form-preview-dialog/admin-event-form-preview-dialog.component.scss b/web-app/admin/src/app/admin/admin-event/admin-event-form/admin-event-form-preview/form-preview-dialog/admin-event-form-preview-dialog.component.scss index 5c4b94481..cd246bd5a 100644 --- a/web-app/admin/src/app/admin/admin-event/admin-event-form/admin-event-form-preview/form-preview-dialog/admin-event-form-preview-dialog.component.scss +++ b/web-app/admin/src/app/admin/admin-event/admin-event-form/admin-event-form-preview/form-preview-dialog/admin-event-form-preview-dialog.component.scss @@ -1,3 +1,64 @@ -.admin-event-form-preview { - margin: 8px 0; +:host { + display: block; +} + +.form-preview-dialog-content { + padding: 0 !important; + margin: 0; + overflow: hidden; +} + +.form-preview-viewport { + width: 100%; + max-height: 70vh; + overflow: auto; + background: #fff; + border-top: 1px solid #eee; + padding: 16px 24px; + box-sizing: border-box; +} + +.form-preview-scale { + width: 100%; + transform-origin: top left; +} + +:host ::ng-deep .form-preview-scale { + font-family: 'Times New Roman', Times, serif; + color: #000; + font-size: 14px; + line-height: 1.15; +} + +:host ::ng-deep .form-preview-scale table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; + table-layout: fixed; +} + +:host ::ng-deep .form-preview-scale td, +:host ::ng-deep .form-preview-scale th { + border: 1px solid #444; + padding: 2px 6px; + vertical-align: middle; + color: #000; +} + +:host ::ng-deep .form-preview-scale input, +:host ::ng-deep .form-preview-scale textarea, +:host ::ng-deep .form-preview-scale select { + font-family: 'Times New Roman', Times, serif; + font-size: 14px; + color: #000; +} + +:host ::ng-deep h2[mat-dialog-title], +:host ::ng-deep .mat-mdc-dialog-title { + color: #1e88e5; +} + +:host ::ng-deep .form-preview-viewport mat-card, +:host ::ng-deep .form-preview-viewport .mat-mdc-card { + box-shadow: none !important; } \ No newline at end of file diff --git a/web-app/admin/src/app/admin/admin-event/admin-event-form/form-details/field-dialog/field-dialog.component.html b/web-app/admin/src/app/admin/admin-event/admin-event-form/form-details/field-dialog/field-dialog.component.html index a9bf93fe8..0b3265e12 100644 --- a/web-app/admin/src/app/admin/admin-event/admin-event-form/form-details/field-dialog/field-dialog.component.html +++ b/web-app/admin/src/app/admin/admin-event/admin-event-form/form-details/field-dialog/field-dialog.component.html @@ -3,15 +3,20 @@

{{ isEditMode ? 'Edit Field' : 'Add New Field' }}

- +
Field Type
- @@ -29,8 +34,14 @@

{{ isEditMode ? 'Edit Field' : 'Add New Field' }}

Field Title *
- +

A field with this name already exists. Please choose a different title. @@ -38,9 +49,16 @@

{{ isEditMode ? 'Edit Field' : 'Add New Field' }}

-
+

@@ -50,7 +68,11 @@

{{ isEditMode ? 'Edit Field' : 'Add New Field' }}

@@ -64,8 +86,13 @@

{{ isEditMode ? 'Edit Field' : 'Add New Field' }}

Minimum Attachments
- +
@@ -74,8 +101,13 @@

{{ isEditMode ? 'Edit Field' : 'Add New Field' }}

Maximum Attachments
- +
@@ -89,10 +121,17 @@

{{ isEditMode ? 'Edit Field' : 'Add New Field' }}

-
@@ -111,7 +150,13 @@

{{ isEditMode ? 'Edit Field' : 'Add New Field' }}

Minimum Value
- +
@@ -120,7 +165,13 @@

{{ isEditMode ? 'Edit Field' : 'Add New Field' }}

Maximum Value
- +
@@ -129,21 +180,34 @@

{{ isEditMode ? 'Edit Field' : 'Add New Field' }}

Default Value
- +
-
+
Default Value
- +
@@ -152,8 +216,13 @@

{{ isEditMode ? 'Edit Field' : 'Add New Field' }}

Default Value
- +
@@ -164,46 +233,87 @@

{{ isEditMode ? 'Edit Field' : 'Add New Field' }}

-
+
Field Options
-
-
- - {{ option.title }} -
- -
-
+ " + class="config-inline-item" + >
Default Value
-
-
+ " + class="config-inline-item" + >
Default Values
-
+ " + >
- - - + - \ No newline at end of file + diff --git a/web-app/admin/src/app/admin/admin-event/admin-event-form/form-details/field-dialog/field-dialog.component.scss b/web-app/admin/src/app/admin/admin-event/admin-event-form/form-details/field-dialog/field-dialog.component.scss index ce12bcf05..163583131 100644 --- a/web-app/admin/src/app/admin/admin-event/admin-event-form/form-details/field-dialog/field-dialog.component.scss +++ b/web-app/admin/src/app/admin/admin-event/admin-event-form/form-details/field-dialog/field-dialog.component.scss @@ -9,49 +9,116 @@ $font-xs: 12px; ::ng-deep .add-field-dialog { .mat-mdc-dialog-container { --mdc-dialog-container-color: #ffffff; + --mdc-dialog-subhead-color: #1976d2; + --mdc-text-button-label-text-color: #333333; + --mat-text-button-state-layer-color: transparent; + padding: 0; } - .mdc-dialog__surface { - border-radius: 16px; + .mdc-dialog__surface, + .mat-mdc-dialog-surface { + border-radius: 8px; overflow: hidden; + background: #ffffff; + } + + .mat-mdc-dialog-title { + padding: 0; } - mat-dialog-content { - padding: 0 24px 24px 24px; + .mat-mdc-dialog-content.dialog-body-padding { + padding: 20px 20px 0 20px !important; + margin: 0 !important; max-height: 70vh; + overflow: auto; } - mat-dialog-actions { - padding: 16px 24px 20px 24px; - border-top: 1px solid #e5e7eb; - margin: 0; - gap: 8px; + .mat-mdc-dialog-actions.dialog-actions { + padding: 18px 20px 18px 20px !important; + margin: 0 !important; + min-height: 72px; + gap: 12px; + border-top: none; + } + + .dialog-action-button { + min-width: 88px !important; + height: 38px !important; + min-height: 38px !important; + padding: 0 16px !important; + border-radius: 4px !important; + font-size: $font-sm !important; + font-weight: 600 !important; + line-height: 38px !important; + text-transform: none !important; + letter-spacing: normal !important; + box-shadow: none !important; + border: 1px solid transparent !important; + } + + .dialog-action-button .mdc-button__label { + line-height: 38px; + } + + .dialog-action-button-cancel { + background-color: #ffffff !important; + color: #333333 !important; + border-color: transparent !important; + } + + .dialog-action-button-cancel:hover { + background-color: #f5f5f5 !important; + } + + .dialog-action-button-primary { + background-color: #3b82f6 !important; + border-color: #3b82f6 !important; + color: #fff !important; + } + + .dialog-action-button-primary:hover:not(:disabled) { + background-color: #d7ebfd !important; + border-color: #d7ebfd !important; + } + + .dialog-action-button-primary:disabled { + background-color: #eef3f7 !important; + color: rgba(25, 118, 210, 0.45) !important; + border-color: #eef3f7 !important; + } + + .dialog-action-button .mat-mdc-button-persistent-ripple, + .dialog-action-button .mat-ripple, + .dialog-action-button .mdc-button__ripple { + display: none; } } .dialog-modal { background: #ffffff; + width: 100%; } .dialog-header { - padding: 20px 24px 16px 24px; - border-bottom: 1px solid #e5e7eb; - background: #f9fafb; + padding: 0; + border-bottom: none; + background: #ffffff; h2[mat-dialog-title] { margin: 0; + padding: 0; font-size: $font-lg; - font-weight: 700; + font-weight: 500; line-height: 1.25; - color: #111827; + color: #1976d2; } } .dialog-form { min-width: 500px; - background-color: white; - border-radius: 12px; - padding-top: 20px; + background-color: #ffffff; + border-radius: 0; + padding: 0; @media (max-width: 640px) { min-width: 0; @@ -59,31 +126,31 @@ $font-xs: 12px; .config-inline-item { display: grid; - grid-template-columns: 175px 1fr; + grid-template-columns: 175px 320px; gap: 16px; - align-items: start; + align-items: center; margin-bottom: 20px; @media (max-width: 768px) { grid-template-columns: 1fr; - gap: 10px; + gap: 8px; } .config-inline-label { display: flex; flex-direction: column; gap: 6px; - padding-top: 8px; + padding-top: 0; strong { font-weight: 700; - color: #111827; + color: #333333; font-size: $font-sm; } .field-hint { font-size: $font-xs; - color: #6b7280; + color: #666666; margin: 0; line-height: 1.45; } @@ -119,7 +186,7 @@ $font-xs: 12px; padding: 8px 10px; background-color: #fffbeb; border: 1px solid #fde68a; - border-radius: 6px; + border-radius: 4px; i { color: #d97706; @@ -128,53 +195,90 @@ $font-xs: 12px; .field-hint { font-size: $font-xs; - color: #6b7280; + color: #666666; margin: 0; line-height: 1.45; } .form-input { width: 100%; - min-height: 40px; - padding: 10px 14px; - border: 1px solid #d1d5db; - border-radius: 8px; + height: 38px; + min-height: 38px; + padding: 8px 12px; + border: 1px solid #dddddd; + border-radius: 3px; font-size: $font-sm; - color: #111827; + color: #555555; background: #ffffff; - transition: all 0.2s ease; box-sizing: border-box; + box-shadow: none; + transition: border-color 0.2s ease; + } - &:focus { - outline: none; - border-color: #3b82f6; - background-color: #ffffff; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); - } + .form-input:focus { + outline: none; + border-color: #1976d2; + background-color: #ffffff; + box-shadow: none; } textarea.form-input { - min-height: 96px; + height: auto; + min-height: 84px; resize: vertical; } select.form-input { - min-height: 40px; - background-color: #fff; + height: 38px; + min-height: 38px; + background-color: #ffffff; cursor: pointer; - appearance: none; - padding-right: 40px; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); - background-position: right 12px center; - background-repeat: no-repeat; - background-size: 18px 18px; - - &:hover { - border-color: #9ca3af; - background-color: #f9fafb; - } + appearance: auto; + padding-right: 12px; + background-image: none; + } + + select.form-input:hover { + border-color: #bbbbbb; + background-color: #ffffff; + } + } + } + + .checkbox-section { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 20px; + margin-left: 0; + + .checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; + + input[type='checkbox'] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: #9a6a45; + } + + span { + font-size: $font-sm; + font-weight: 400; + color: #333333; } } + + .field-hint { + font-size: $font-xs; + color: #666666; + margin: 0 0 0 26px; + line-height: 1.45; + } } .options-list { @@ -185,30 +289,29 @@ $font-xs: 12px; display: flex; align-items: center; gap: 8px; - padding: 12px; - background: #f9fafb; - border: 1px solid #e5e7eb; - border-radius: 8px; - margin-bottom: 8px; + padding: 8px 0; + background: transparent; + border: none; + border-radius: 0; + margin-bottom: 4px; .option-title { flex: 1; font-size: $font-sm; - color: #111827; + color: #333333; } .icon-button { padding: 4px 8px; border: none; background: transparent; - color: #6b7280; + color: #555555; cursor: pointer; border-radius: 4px; - transition: all 0.2s; &:hover { - background: #e5e7eb; - color: #374151; + background: #eeeeee; + color: #333333; } &.delete-button:hover { @@ -227,30 +330,30 @@ $font-xs: 12px; } .action-button { - padding: 10px 14px; + padding: 0 14px; border: none; - border-radius: 8px; + border-radius: 4px; font-size: $font-sm; font-weight: 600; cursor: pointer; - transition: all 0.2s; white-space: nowrap; - min-height: 40px; + height: 38px; + min-height: 38px; display: inline-flex; align-items: center; gap: 8px; &.btn-primary { - background-color: #2563eb; - color: white; + background-color: #1976d2; + color: #ffffff; &:hover:not(:disabled) { - background-color: #1d4ed8; + background-color: #1565c0; } &:disabled { - background-color: #d1d5db; - color: #6b7280; + background-color: #dddddd; + color: #777777; cursor: not-allowed; } } @@ -270,51 +373,8 @@ $font-xs: 12px; span { font-size: $font-sm; - color: #111827; + color: #333333; } } } - - .checkbox-section { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 20px; - - .checkbox-label { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - user-select: none; - - input[type='checkbox'] { - width: 18px; - height: 18px; - cursor: pointer; - } - - span { - font-size: $font-sm; - font-weight: 500; - color: #333; - } - } - - .field-hint { - font-size: $font-xs; - color: #6b7280; - margin: 0 0 0 26px; - line-height: 1.45; - } - } } - -:host ::ng-deep .add-field-dialog .mat-mdc-button, -:host ::ng-deep .add-field-dialog button[mat-button] { - min-height: 40px; - border-radius: 8px; - font-size: $font-sm; - font-weight: 600; - text-transform: none; -} \ No newline at end of file diff --git a/web-app/admin/src/app/admin/admin-event/admin-event-form/form-details/form-details.component.html b/web-app/admin/src/app/admin/admin-event/admin-event-form/form-details/form-details.component.html index 9cf4c1351..61f877db1 100644 --- a/web-app/admin/src/app/admin/admin-event/admin-event-form/form-details/form-details.component.html +++ b/web-app/admin/src/app/admin/admin-event/admin-event-form/form-details/form-details.component.html @@ -1,7 +1,6 @@
-