@omersx/bemms 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +203 -2
- package/drizzle.config.ts +11 -0
- package/next.config.ts +51 -0
- package/package.json +99 -3
- package/postcss.config.mjs +7 -0
- package/public/hospital_demo_devices.csv +32 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-maskable-512.png +0 -0
- package/public/manifest.webmanifest +31 -0
- package/public/sw.js +177 -0
- package/src/app/(auth)/layout.tsx +20 -0
- package/src/app/(auth)/login/page.tsx +135 -0
- package/src/app/(dashboard)/admin/audit-logs/page.tsx +178 -0
- package/src/app/(dashboard)/admin/data/page.tsx +37 -0
- package/src/app/(dashboard)/admin/departments/[deptId]/edit/page.tsx +206 -0
- package/src/app/(dashboard)/admin/departments/[deptId]/page.tsx +71 -0
- package/src/app/(dashboard)/admin/departments/new/page.tsx +6 -0
- package/src/app/(dashboard)/admin/departments/page.tsx +6 -0
- package/src/app/(dashboard)/admin/general/page.tsx +374 -0
- package/src/app/(dashboard)/admin/hospitals/[hospitalId]/edit/page.tsx +304 -0
- package/src/app/(dashboard)/admin/hospitals/[hospitalId]/page.tsx +94 -0
- package/src/app/(dashboard)/admin/hospitals/new/page.tsx +302 -0
- package/src/app/(dashboard)/admin/hospitals/page.tsx +6 -0
- package/src/app/(dashboard)/admin/layout.tsx +49 -0
- package/src/app/(dashboard)/admin/organizations/[orgId]/edit/page.tsx +172 -0
- package/src/app/(dashboard)/admin/organizations/[orgId]/page.tsx +82 -0
- package/src/app/(dashboard)/admin/organizations/new/page.tsx +150 -0
- package/src/app/(dashboard)/admin/organizations/page.tsx +6 -0
- package/src/app/(dashboard)/admin/page.tsx +258 -0
- package/src/app/(dashboard)/admin/roles/page.tsx +153 -0
- package/src/app/(dashboard)/admin/settings/page.tsx +69 -0
- package/src/app/(dashboard)/admin/users/[userId]/edit/page.tsx +153 -0
- package/src/app/(dashboard)/admin/users/[userId]/page.tsx +317 -0
- package/src/app/(dashboard)/admin/users/invite/page.tsx +3 -0
- package/src/app/(dashboard)/admin/users/new/page.tsx +5 -0
- package/src/app/(dashboard)/admin/users/page.tsx +23 -0
- package/src/app/(dashboard)/dashboard/page.tsx +462 -0
- package/src/app/(dashboard)/departments/[deptId]/page.tsx +39 -0
- package/src/app/(dashboard)/departments/new/page.tsx +330 -0
- package/src/app/(dashboard)/departments/page.tsx +52 -0
- package/src/app/(dashboard)/devices/[id]/edit/page.tsx +131 -0
- package/src/app/(dashboard)/devices/[id]/page.tsx +408 -0
- package/src/app/(dashboard)/devices/[id]/qr/page.tsx +54 -0
- package/src/app/(dashboard)/devices/new/page.tsx +382 -0
- package/src/app/(dashboard)/devices/page.tsx +80 -0
- package/src/app/(dashboard)/engineering-team/page.tsx +41 -0
- package/src/app/(dashboard)/layout.tsx +43 -0
- package/src/app/(dashboard)/maintenance/checklists/page.tsx +57 -0
- package/src/app/(dashboard)/maintenance/page.tsx +231 -0
- package/src/app/(dashboard)/maintenance/plans/page.tsx +142 -0
- package/src/app/(dashboard)/maintenance/tasks/[id]/page.tsx +997 -0
- package/src/app/(dashboard)/maintenance/tasks/page.tsx +119 -0
- package/src/app/(dashboard)/more/page.tsx +310 -0
- package/src/app/(dashboard)/my-department/page.tsx +151 -0
- package/src/app/(dashboard)/notifications/notification-list.tsx +103 -0
- package/src/app/(dashboard)/notifications/page.tsx +59 -0
- package/src/app/(dashboard)/profile/page.tsx +27 -0
- package/src/app/(dashboard)/reports/costs/page.tsx +127 -0
- package/src/app/(dashboard)/reports/history/page.tsx +105 -0
- package/src/app/(dashboard)/reports/inventory/page.tsx +116 -0
- package/src/app/(dashboard)/reports/maintenance/page.tsx +136 -0
- package/src/app/(dashboard)/reports/page.tsx +120 -0
- package/src/app/(dashboard)/reports/tickets/page.tsx +139 -0
- package/src/app/(dashboard)/reports/workload/page.tsx +135 -0
- package/src/app/(dashboard)/rounds/[id]/page.tsx +197 -0
- package/src/app/(dashboard)/rounds/history/page.tsx +82 -0
- package/src/app/(dashboard)/rounds/page.tsx +107 -0
- package/src/app/(dashboard)/scan/[code]/page.tsx +154 -0
- package/src/app/(dashboard)/scan/page.tsx +73 -0
- package/src/app/(dashboard)/team/page.tsx +42 -0
- package/src/app/(dashboard)/tickets/[id]/page.tsx +465 -0
- package/src/app/(dashboard)/tickets/create/page.tsx +722 -0
- package/src/app/(dashboard)/tickets/page.tsx +55 -0
- package/src/app/(dashboard)/tickets/triage/page.tsx +161 -0
- package/src/app/api/admin/data/export/route.ts +316 -0
- package/src/app/api/auth/[...nextauth]/route.ts +3 -0
- package/src/app/api/health/route.ts +46 -0
- package/src/app/api/notifications/sse/route.ts +91 -0
- package/src/app/api/notifications/subscribe/route.ts +54 -0
- package/src/app/api/notifications/vapid-key/route.ts +7 -0
- package/src/app/api/reports/export/route.ts +102 -0
- package/src/app/api/tickets/[id]/attachments/route.ts +129 -0
- package/src/app/api/tickets/[id]/live-sync/route.ts +52 -0
- package/src/app/error.tsx +54 -0
- package/src/app/globals.css +89 -0
- package/src/app/layout.tsx +59 -0
- package/src/app/not-found.tsx +32 -0
- package/src/app/page.tsx +5 -0
- package/src/components/admin/add-department-user-dialog.tsx +759 -0
- package/src/components/admin/add-engineer-dialog.tsx +773 -0
- package/src/components/admin/add-user-type-dialog.tsx +89 -0
- package/src/components/admin/audit-log-export.tsx +113 -0
- package/src/components/admin/audit-log-filters.tsx +255 -0
- package/src/components/admin/audit-log-table.tsx +383 -0
- package/src/components/admin/data-management/danger-zone.tsx +301 -0
- package/src/components/admin/data-management/data-management-tabs.tsx +61 -0
- package/src/components/admin/data-management/export-center.tsx +301 -0
- package/src/components/admin/data-management/import-wizard.tsx +558 -0
- package/src/components/admin/settings-forms/features-form.tsx +115 -0
- package/src/components/admin/settings-forms/health-panel.tsx +182 -0
- package/src/components/admin/settings-forms/localization-form.tsx +203 -0
- package/src/components/admin/settings-forms/retention-form.tsx +164 -0
- package/src/components/admin/settings-forms/security-form.tsx +186 -0
- package/src/components/admin/system-settings-tabs.tsx +88 -0
- package/src/components/admin/user-role-dialog.tsx +293 -0
- package/src/components/admin/user-status-dialog.tsx +166 -0
- package/src/components/admin/users-page-client.tsx +514 -0
- package/src/components/dashboard/action-buttons.tsx +93 -0
- package/src/components/dashboard/admin-alerts.tsx +86 -0
- package/src/components/dashboard/availability-alerts.tsx +65 -0
- package/src/components/dashboard/maintenance-due-list.tsx +87 -0
- package/src/components/dashboard/round-widget.tsx +110 -0
- package/src/components/dashboard/stat-card.tsx +38 -0
- package/src/components/dashboard/ticket-list-compact.tsx +86 -0
- package/src/components/departments/department-cards-view.tsx +244 -0
- package/src/components/departments/department-detail-view.tsx +467 -0
- package/src/components/departments/department-visuals.tsx +190 -0
- package/src/components/departments/my-department-view.tsx +333 -0
- package/src/components/devices/change-status-dialog.tsx +146 -0
- package/src/components/devices/device-action-buttons.tsx +125 -0
- package/src/components/devices/device-availability-banner.tsx +59 -0
- package/src/components/devices/device-badges.tsx +55 -0
- package/src/components/devices/device-documents-tab.tsx +93 -0
- package/src/components/devices/device-maintenance-tab.tsx +113 -0
- package/src/components/devices/device-mini-qr.tsx +109 -0
- package/src/components/devices/device-qr-card.tsx +187 -0
- package/src/components/devices/device-signatures-tab.tsx +607 -0
- package/src/components/devices/device-tickets-tab.tsx +139 -0
- package/src/components/devices/device-timelines.tsx +124 -0
- package/src/components/devices/qr-camera-scanner.tsx +167 -0
- package/src/components/devices/qr-code-display.tsx +139 -0
- package/src/components/devices/quick-add-category-dialog.tsx +135 -0
- package/src/components/devices/quick-add-manufacturer-dialog.tsx +212 -0
- package/src/components/devices/transfer-device-dialog.tsx +190 -0
- package/src/components/layout/header.tsx +80 -0
- package/src/components/layout/mobile-nav.tsx +91 -0
- package/src/components/layout/more-item-badge.tsx +15 -0
- package/src/components/layout/nav-badge.tsx +72 -0
- package/src/components/layout/sidebar.tsx +297 -0
- package/src/components/maintenance/maintenance-badges.tsx +579 -0
- package/src/components/maintenance/maintenance-nav.tsx +107 -0
- package/src/components/maintenance/parts-costs-manager.tsx +909 -0
- package/src/components/maintenance/signature-card.tsx +413 -0
- package/src/components/maintenance/task-action-bar.tsx +955 -0
- package/src/components/notifications/notification-bell.tsx +367 -0
- package/src/components/notifications/notification-item.tsx +93 -0
- package/src/components/notifications/notification-preferences.tsx +182 -0
- package/src/components/profile/user-profile-view.tsx +626 -0
- package/src/components/providers/nav-counts-provider.tsx +235 -0
- package/src/components/providers/session-provider.tsx +7 -0
- package/src/components/providers/theme-provider.tsx +124 -0
- package/src/components/pwa/install-prompt.tsx +82 -0
- package/src/components/pwa/sw-register.tsx +43 -0
- package/src/components/reports/export-button.tsx +62 -0
- package/src/components/reports/filter-chips.tsx +47 -0
- package/src/components/reports/summary-cards.tsx +40 -0
- package/src/components/reports/transparency-header.tsx +54 -0
- package/src/components/rounds/department-visit-sheet.tsx +199 -0
- package/src/components/rounds/mention-textarea.tsx +187 -0
- package/src/components/rounds/round-calendar.tsx +233 -0
- package/src/components/rounds/round-department-list.tsx +281 -0
- package/src/components/rounds/round-history-client.tsx +218 -0
- package/src/components/rounds/start-round-card.tsx +184 -0
- package/src/components/shared/confirm-dialog.tsx +67 -0
- package/src/components/shared/data-table.tsx +104 -0
- package/src/components/shared/page-header.tsx +20 -0
- package/src/components/shared/sign-out-button.tsx +52 -0
- package/src/components/shared/status-badge.tsx +42 -0
- package/src/components/shared/table-search.tsx +55 -0
- package/src/components/signatures/sign-action-dialog.tsx +253 -0
- package/src/components/signatures/signature-history.tsx +121 -0
- package/src/components/signatures/signature-status-panel.tsx +164 -0
- package/src/components/team/department-coverage.tsx +141 -0
- package/src/components/team/edit-engineer-dialog.tsx +242 -0
- package/src/components/team/engineer-card.tsx +180 -0
- package/src/components/team/engineer-details-dialog.tsx +432 -0
- package/src/components/team/schedule-calendar.tsx +204 -0
- package/src/components/team/staff-engineer-team-view.tsx +368 -0
- package/src/components/team/team-header-stats.tsx +88 -0
- package/src/components/team/team-page-client.tsx +216 -0
- package/src/components/theme/theme-selector-card.tsx +63 -0
- package/src/components/theme/theme-toggle.tsx +65 -0
- package/src/components/tickets/staff-status-label.tsx +58 -0
- package/src/components/tickets/staff-ticket-card.tsx +109 -0
- package/src/components/tickets/staff-ticket-progress-tracker.tsx +405 -0
- package/src/components/tickets/staff-tickets-view.tsx +182 -0
- package/src/components/tickets/ticket-action-buttons.tsx +873 -0
- package/src/components/tickets/ticket-auto-refresh.tsx +197 -0
- package/src/components/tickets/ticket-badges.tsx +43 -0
- package/src/components/tickets/ticket-card.tsx +197 -0
- package/src/components/tickets/ticket-cards-view.tsx +395 -0
- package/src/components/tickets/ticket-comment-section.tsx +684 -0
- package/src/components/tickets/ticket-dispatch-status.tsx +184 -0
- package/src/components/tickets/ticket-list-auto-refresh.tsx +55 -0
- package/src/components/tickets/ticket-reporter-profile.tsx +235 -0
- package/src/components/tickets/ticket-signatures-panel.tsx +32 -0
- package/src/components/tickets/ticket-timeline.tsx +131 -0
- package/src/components/tickets/ticket-triage-panel.tsx +124 -0
- package/src/components/tickets/tickets-nav.tsx +107 -0
- package/src/components/ui/avatar.tsx +43 -0
- package/src/components/ui/badge.tsx +34 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/card.tsx +77 -0
- package/src/components/ui/checkbox.tsx +31 -0
- package/src/components/ui/dialog.tsx +123 -0
- package/src/components/ui/dropdown-menu.tsx +126 -0
- package/src/components/ui/form.tsx +179 -0
- package/src/components/ui/input.tsx +24 -0
- package/src/components/ui/label.tsx +27 -0
- package/src/components/ui/popover.tsx +34 -0
- package/src/components/ui/progress.tsx +36 -0
- package/src/components/ui/scroll-area.tsx +47 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +25 -0
- package/src/components/ui/sheet.tsx +131 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +121 -0
- package/src/components/ui/tabs.tsx +62 -0
- package/src/components/ui/textarea.tsx +23 -0
- package/src/components/ui/tooltip.tsx +27 -0
- package/src/lib/actions/__tests__/devices.test.ts +176 -0
- package/src/lib/actions/__tests__/signatures.test.ts +303 -0
- package/src/lib/actions/__tests__/tickets.test.ts +243 -0
- package/src/lib/actions/audit-logs.ts +298 -0
- package/src/lib/actions/checklists.ts +429 -0
- package/src/lib/actions/dashboard.ts +358 -0
- package/src/lib/actions/data-management.ts +723 -0
- package/src/lib/actions/departments.ts +369 -0
- package/src/lib/actions/device-categories.ts +117 -0
- package/src/lib/actions/devices.ts +748 -0
- package/src/lib/actions/engineer-rounds.ts +973 -0
- package/src/lib/actions/engineers.ts +285 -0
- package/src/lib/actions/hospitals.ts +148 -0
- package/src/lib/actions/locations.ts +115 -0
- package/src/lib/actions/maintenance-plans.ts +316 -0
- package/src/lib/actions/maintenance-tasks.ts +415 -0
- package/src/lib/actions/manufacturers.ts +132 -0
- package/src/lib/actions/nav-counts.ts +144 -0
- package/src/lib/actions/notifications.ts +136 -0
- package/src/lib/actions/organizations.ts +99 -0
- package/src/lib/actions/qr.ts +147 -0
- package/src/lib/actions/report-history.ts +77 -0
- package/src/lib/actions/reports.ts +192 -0
- package/src/lib/actions/roles.ts +32 -0
- package/src/lib/actions/signature-auth.ts +377 -0
- package/src/lib/actions/signatures.ts +532 -0
- package/src/lib/actions/system-settings.ts +274 -0
- package/src/lib/actions/tickets.ts +1445 -0
- package/src/lib/actions/users.ts +740 -0
- package/src/lib/audit/index.ts +103 -0
- package/src/lib/auth/__tests__/rbac.test.ts +33 -0
- package/src/lib/auth/index.ts +338 -0
- package/src/lib/auth/password.ts +10 -0
- package/src/lib/auth/rbac.ts +103 -0
- package/src/lib/auth/types.ts +57 -0
- package/src/lib/constants/departments.ts +16 -0
- package/src/lib/constants/manufacturers.ts +32 -0
- package/src/lib/db/index.ts +23 -0
- package/src/lib/db/schema/audit-logs.ts +42 -0
- package/src/lib/db/schema/departments.ts +39 -0
- package/src/lib/db/schema/device-categories.ts +38 -0
- package/src/lib/db/schema/devices.ts +195 -0
- package/src/lib/db/schema/engineer-profiles.ts +63 -0
- package/src/lib/db/schema/engineer-rounds.ts +115 -0
- package/src/lib/db/schema/enums.ts +148 -0
- package/src/lib/db/schema/hospitals.ts +34 -0
- package/src/lib/db/schema/index.ts +24 -0
- package/src/lib/db/schema/locations.ts +46 -0
- package/src/lib/db/schema/maintenance-plans.ts +99 -0
- package/src/lib/db/schema/maintenance-records.ts +161 -0
- package/src/lib/db/schema/maintenance-tasks.ts +196 -0
- package/src/lib/db/schema/manufacturers.ts +60 -0
- package/src/lib/db/schema/notifications.ts +48 -0
- package/src/lib/db/schema/organizations.ts +24 -0
- package/src/lib/db/schema/report-history.ts +28 -0
- package/src/lib/db/schema/roles.ts +59 -0
- package/src/lib/db/schema/sessions.ts +46 -0
- package/src/lib/db/schema/signature-events.ts +48 -0
- package/src/lib/db/schema/signature-policies.ts +60 -0
- package/src/lib/db/schema/signatures.ts +87 -0
- package/src/lib/db/schema/tickets.ts +169 -0
- package/src/lib/db/schema/user-assignments.ts +85 -0
- package/src/lib/db/schema/users.ts +39 -0
- package/src/lib/db/seed.ts +131 -0
- package/src/lib/logger/__tests__/logger.test.ts +154 -0
- package/src/lib/logger/formatters.ts +159 -0
- package/src/lib/logger/index.ts +184 -0
- package/src/lib/logger/types.ts +48 -0
- package/src/lib/notifications/create.ts +71 -0
- package/src/lib/notifications/dispatcher.ts +490 -0
- package/src/lib/notifications/index.ts +7 -0
- package/src/lib/notifications/push-sender.ts +128 -0
- package/src/lib/notifications/push.ts +64 -0
- package/src/lib/notifications/triggers.ts +166 -0
- package/src/lib/utils/__tests__/crypto.test.ts +30 -0
- package/src/lib/utils/__tests__/export.test.ts +55 -0
- package/src/lib/utils/__tests__/nav-badge.test.ts +147 -0
- package/src/lib/utils/__tests__/staff-status.test.ts +50 -0
- package/src/lib/utils/attestations.ts +19 -0
- package/src/lib/utils/crypto.ts +18 -0
- package/src/lib/utils/csv-parser.ts +181 -0
- package/src/lib/utils/export.ts +45 -0
- package/src/lib/utils/nav-badge.ts +225 -0
- package/src/lib/utils/sound.ts +152 -0
- package/src/lib/utils/staff-status.ts +115 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/validators/__tests__/auth.test.ts +127 -0
- package/src/lib/validators/__tests__/data-management.test.ts +182 -0
- package/src/lib/validators/__tests__/devices-profile.test.ts +129 -0
- package/src/lib/validators/__tests__/devices.test.ts +98 -0
- package/src/lib/validators/__tests__/engineer-rounds.test.ts +170 -0
- package/src/lib/validators/__tests__/maintenance.test.ts +178 -0
- package/src/lib/validators/__tests__/signatures.test.ts +154 -0
- package/src/lib/validators/__tests__/system-settings.test.ts +119 -0
- package/src/lib/validators/__tests__/tickets.test.ts +176 -0
- package/src/lib/validators/__tests__/users.test.ts +92 -0
- package/src/lib/validators/auth.ts +32 -0
- package/src/lib/validators/data-management.ts +67 -0
- package/src/lib/validators/departments.ts +14 -0
- package/src/lib/validators/devices.ts +94 -0
- package/src/lib/validators/engineer-rounds.ts +70 -0
- package/src/lib/validators/hospitals.ts +17 -0
- package/src/lib/validators/locations.ts +15 -0
- package/src/lib/validators/maintenance.ts +211 -0
- package/src/lib/validators/organizations.ts +11 -0
- package/src/lib/validators/reports.ts +31 -0
- package/src/lib/validators/signatures.ts +49 -0
- package/src/lib/validators/system-settings.ts +55 -0
- package/src/lib/validators/tickets.ts +125 -0
- package/src/lib/validators/users.ts +43 -0
- package/src/middleware.ts +45 -0
- package/tailwind.config.ts +76 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { db } from '@/lib/db';
|
|
4
|
+
import {
|
|
5
|
+
devices,
|
|
6
|
+
deviceCategories,
|
|
7
|
+
manufacturers,
|
|
8
|
+
departments,
|
|
9
|
+
hospitals,
|
|
10
|
+
organizations,
|
|
11
|
+
users,
|
|
12
|
+
serviceTickets,
|
|
13
|
+
maintenanceTasks,
|
|
14
|
+
maintenanceScheduleOccurrences,
|
|
15
|
+
maintenancePlans,
|
|
16
|
+
maintenanceRecords,
|
|
17
|
+
maintenanceParts,
|
|
18
|
+
maintenanceCosts,
|
|
19
|
+
auditLogs,
|
|
20
|
+
engineerRounds,
|
|
21
|
+
roundDepartmentVisits,
|
|
22
|
+
roundVisitNotes,
|
|
23
|
+
} from '@/lib/db/schema';
|
|
24
|
+
import { requireAuth, requireRole } from '@/lib/auth/rbac';
|
|
25
|
+
import { createAuditLog } from '@/lib/audit';
|
|
26
|
+
import { verifyPassword } from '@/lib/auth/password';
|
|
27
|
+
import { parseCSV } from '@/lib/utils/csv-parser';
|
|
28
|
+
import {
|
|
29
|
+
deviceImportRowSchema,
|
|
30
|
+
type DeviceImportValidationResult,
|
|
31
|
+
departmentImportRowSchema,
|
|
32
|
+
type DepartmentImportValidationResult,
|
|
33
|
+
type PurgeDataPayload,
|
|
34
|
+
purgeDataSchema,
|
|
35
|
+
} from '@/lib/validators/data-management';
|
|
36
|
+
import { eq, sql, inArray } from 'drizzle-orm';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validates a batch of device records from raw CSV content.
|
|
40
|
+
*/
|
|
41
|
+
export async function validateDeviceImport(csvContent: string): Promise<{
|
|
42
|
+
success: boolean;
|
|
43
|
+
totalRows: number;
|
|
44
|
+
validCount: number;
|
|
45
|
+
warningCount: number;
|
|
46
|
+
errorCount: number;
|
|
47
|
+
rows: DeviceImportValidationResult[];
|
|
48
|
+
error?: string;
|
|
49
|
+
}> {
|
|
50
|
+
const session = await requireAuth();
|
|
51
|
+
await requireRole('SYS_ADMIN', 'ORG_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG');
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const rawRecords = parseCSV(csvContent);
|
|
55
|
+
if (rawRecords.length === 0) {
|
|
56
|
+
return {
|
|
57
|
+
success: false,
|
|
58
|
+
totalRows: 0,
|
|
59
|
+
validCount: 0,
|
|
60
|
+
warningCount: 0,
|
|
61
|
+
errorCount: 0,
|
|
62
|
+
rows: [],
|
|
63
|
+
error: 'No valid data rows found in CSV. Please ensure the file includes column headers.',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Resolve target organization and hospital
|
|
68
|
+
let org = await db.query.organizations.findFirst();
|
|
69
|
+
if (session.organizationId) {
|
|
70
|
+
const foundOrg = await db.query.organizations.findFirst({
|
|
71
|
+
where: eq(organizations.id, session.organizationId),
|
|
72
|
+
});
|
|
73
|
+
if (foundOrg) org = foundOrg;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!org) {
|
|
77
|
+
return {
|
|
78
|
+
success: false,
|
|
79
|
+
totalRows: 0,
|
|
80
|
+
validCount: 0,
|
|
81
|
+
warningCount: 0,
|
|
82
|
+
errorCount: 0,
|
|
83
|
+
rows: [],
|
|
84
|
+
error: 'Organization not found. Please configure an organization first.',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Load existing database entities for validation
|
|
89
|
+
const [existingDevices, existingCategories, existingManufacturers, existingDepartments] =
|
|
90
|
+
await Promise.all([
|
|
91
|
+
db.query.devices.findMany({
|
|
92
|
+
where: eq(devices.organizationId, org.id),
|
|
93
|
+
columns: { assetNumber: true, internalCode: true },
|
|
94
|
+
}),
|
|
95
|
+
db.query.deviceCategories.findMany({
|
|
96
|
+
where: eq(deviceCategories.organizationId, org.id),
|
|
97
|
+
}),
|
|
98
|
+
db.query.manufacturers.findMany({
|
|
99
|
+
where: eq(manufacturers.organizationId, org.id),
|
|
100
|
+
}),
|
|
101
|
+
db.query.departments.findMany(),
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const existingAssetNumbers = new Set(existingDevices.map((d) => d.assetNumber.toLowerCase().trim()));
|
|
105
|
+
const existingCodes = new Set(existingDevices.map((d) => d.internalCode.toLowerCase().trim()));
|
|
106
|
+
|
|
107
|
+
const categoryMap = new Map(existingCategories.map((c) => [c.name.toLowerCase().trim(), c]));
|
|
108
|
+
const manufacturerMap = new Map(existingManufacturers.map((m) => [m.name.toLowerCase().trim(), m]));
|
|
109
|
+
const departmentMap = new Map<string, any>();
|
|
110
|
+
existingDepartments.forEach((d) => {
|
|
111
|
+
departmentMap.set(d.name.toLowerCase().trim(), d);
|
|
112
|
+
if (d.code) departmentMap.set(d.code.toLowerCase().trim(), d);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const results: DeviceImportValidationResult[] = [];
|
|
116
|
+
let validCount = 0;
|
|
117
|
+
let warningCount = 0;
|
|
118
|
+
let errorCount = 0;
|
|
119
|
+
|
|
120
|
+
const currentBatchAssetNumbers = new Set<string>();
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < rawRecords.length; i++) {
|
|
123
|
+
const row = rawRecords[i];
|
|
124
|
+
const rowNumber = i + 2; // Account for 1-based index and header line
|
|
125
|
+
|
|
126
|
+
// Map potential CSV header variants
|
|
127
|
+
const mappedRow = {
|
|
128
|
+
assetNumber: row.asset_number || row.assetnumber || row.asset_id || row.tag || '',
|
|
129
|
+
name: row.name || row.device_name || row.equipment_name || row.title || '',
|
|
130
|
+
model: row.model || row.device_model || row.model_name || '',
|
|
131
|
+
serialNumber: row.serial_number || row.serialnumber || row.serial || '',
|
|
132
|
+
categoryName: row.category || row.category_name || row.device_category || '',
|
|
133
|
+
manufacturerName: row.manufacturer || row.manufacturer_name || row.vendor || row.make || '',
|
|
134
|
+
departmentName: row.department || row.department_name || row.dept || '',
|
|
135
|
+
locationDescription: row.location || row.location_description || row.room || '',
|
|
136
|
+
riskClassification: (row.risk_classification || row.risk || 'class_i') as any,
|
|
137
|
+
criticalityLevel: (row.criticality || row.criticality_level || 'medium') as any,
|
|
138
|
+
purchaseDate: row.purchase_date || row.purchasedate || '',
|
|
139
|
+
purchaseCost: row.purchase_cost || row.cost || row.price || '',
|
|
140
|
+
installationDate: row.installation_date || row.install_date || '',
|
|
141
|
+
warrantyStartDate: row.warranty_start_date || row.warranty_start || '',
|
|
142
|
+
warrantyEndDate: row.warranty_end_date || row.warranty_end || '',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const parseResult = deviceImportRowSchema.safeParse(mappedRow);
|
|
146
|
+
const messages: string[] = [];
|
|
147
|
+
let status: 'valid' | 'warning' | 'error' = 'valid';
|
|
148
|
+
|
|
149
|
+
if (!parseResult.success) {
|
|
150
|
+
status = 'error';
|
|
151
|
+
parseResult.error.errors.forEach((err) => messages.push(err.message));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const validData = parseResult.success ? parseResult.data : mappedRow;
|
|
155
|
+
const cleanAsset = validData.assetNumber.toLowerCase().trim();
|
|
156
|
+
|
|
157
|
+
// Duplicate asset number checks
|
|
158
|
+
if (cleanAsset) {
|
|
159
|
+
if (existingAssetNumbers.has(cleanAsset)) {
|
|
160
|
+
status = 'error';
|
|
161
|
+
messages.push(`Asset number "${validData.assetNumber}" already exists in the database.`);
|
|
162
|
+
} else if (currentBatchAssetNumbers.has(cleanAsset)) {
|
|
163
|
+
status = 'error';
|
|
164
|
+
messages.push(`Duplicate asset number "${validData.assetNumber}" within this CSV file.`);
|
|
165
|
+
} else {
|
|
166
|
+
currentBatchAssetNumbers.add(cleanAsset);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Department resolution
|
|
171
|
+
let resolvedDepartmentId: string | undefined;
|
|
172
|
+
const cleanDept = validData.departmentName.toLowerCase().trim();
|
|
173
|
+
const matchedDept = departmentMap.get(cleanDept);
|
|
174
|
+
if (matchedDept) {
|
|
175
|
+
resolvedDepartmentId = matchedDept.id;
|
|
176
|
+
} else if (existingDepartments.length > 0) {
|
|
177
|
+
// Fallback to first department with a warning if unspecified
|
|
178
|
+
resolvedDepartmentId = existingDepartments[0].id;
|
|
179
|
+
if (status !== 'error') {
|
|
180
|
+
status = 'warning';
|
|
181
|
+
messages.push(`Department "${validData.departmentName}" not found; defaulted to "${existingDepartments[0].name}".`);
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
status = 'error';
|
|
185
|
+
messages.push('No departments exist. Please register a department first.');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Category resolution
|
|
189
|
+
let resolvedCategoryId: string | undefined;
|
|
190
|
+
let willCreateCategory = false;
|
|
191
|
+
const cleanCat = validData.categoryName.toLowerCase().trim();
|
|
192
|
+
const matchedCat = categoryMap.get(cleanCat);
|
|
193
|
+
if (matchedCat) {
|
|
194
|
+
resolvedCategoryId = matchedCat.id;
|
|
195
|
+
} else {
|
|
196
|
+
willCreateCategory = true;
|
|
197
|
+
if (status !== 'error') {
|
|
198
|
+
status = 'warning';
|
|
199
|
+
messages.push(`Category "${validData.categoryName}" will be auto-created.`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Manufacturer resolution
|
|
204
|
+
let resolvedManufacturerId: string | undefined;
|
|
205
|
+
let willCreateManufacturer = false;
|
|
206
|
+
const cleanMfr = validData.manufacturerName.toLowerCase().trim();
|
|
207
|
+
const matchedMfr = manufacturerMap.get(cleanMfr);
|
|
208
|
+
if (matchedMfr) {
|
|
209
|
+
resolvedManufacturerId = matchedMfr.id;
|
|
210
|
+
} else if (validData.manufacturerName) {
|
|
211
|
+
willCreateManufacturer = true;
|
|
212
|
+
if (status !== 'error') {
|
|
213
|
+
status = 'warning';
|
|
214
|
+
messages.push(`Manufacturer "${validData.manufacturerName}" will be auto-created.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (status === 'error') {
|
|
219
|
+
errorCount++;
|
|
220
|
+
} else if (status === 'warning') {
|
|
221
|
+
warningCount++;
|
|
222
|
+
} else {
|
|
223
|
+
validCount++;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
results.push({
|
|
227
|
+
rowNumber,
|
|
228
|
+
data: validData as any,
|
|
229
|
+
status,
|
|
230
|
+
messages,
|
|
231
|
+
resolvedCategoryId,
|
|
232
|
+
resolvedManufacturerId,
|
|
233
|
+
resolvedDepartmentId,
|
|
234
|
+
willCreateCategory,
|
|
235
|
+
willCreateManufacturer,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
success: true,
|
|
241
|
+
totalRows: rawRecords.length,
|
|
242
|
+
validCount,
|
|
243
|
+
warningCount,
|
|
244
|
+
errorCount,
|
|
245
|
+
rows: results,
|
|
246
|
+
};
|
|
247
|
+
} catch (error: any) {
|
|
248
|
+
return {
|
|
249
|
+
success: false,
|
|
250
|
+
totalRows: 0,
|
|
251
|
+
validCount: 0,
|
|
252
|
+
warningCount: 0,
|
|
253
|
+
errorCount: 0,
|
|
254
|
+
rows: [],
|
|
255
|
+
error: error.message || 'Failed to parse and validate CSV file.',
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Commits pre-validated device records to the database.
|
|
262
|
+
*/
|
|
263
|
+
export async function executeDeviceImport(rows: DeviceImportValidationResult[]): Promise<{
|
|
264
|
+
success: boolean;
|
|
265
|
+
importedCount: number;
|
|
266
|
+
error?: string;
|
|
267
|
+
}> {
|
|
268
|
+
const session = await requireAuth();
|
|
269
|
+
await requireRole('SYS_ADMIN', 'ORG_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG');
|
|
270
|
+
|
|
271
|
+
// Filter out any error rows
|
|
272
|
+
const importableRows = rows.filter((r) => r.status === 'valid' || r.status === 'warning');
|
|
273
|
+
if (importableRows.length === 0) {
|
|
274
|
+
return { success: false, importedCount: 0, error: 'No valid rows available to import.' };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Resolve target organization and hospital
|
|
278
|
+
let org = await db.query.organizations.findFirst();
|
|
279
|
+
if (session.organizationId) {
|
|
280
|
+
const foundOrg = await db.query.organizations.findFirst({
|
|
281
|
+
where: eq(organizations.id, session.organizationId),
|
|
282
|
+
});
|
|
283
|
+
if (foundOrg) org = foundOrg;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const defaultHospital = await db.query.hospitals.findFirst();
|
|
287
|
+
if (!org || !defaultHospital) {
|
|
288
|
+
return { success: false, importedCount: 0, error: 'Organization or hospital facility not found.' };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
return await db.transaction(async (tx) => {
|
|
293
|
+
const createdCategories = new Map<string, string>();
|
|
294
|
+
const createdManufacturers = new Map<string, string>();
|
|
295
|
+
|
|
296
|
+
let importedCount = 0;
|
|
297
|
+
|
|
298
|
+
for (const rowItem of importableRows) {
|
|
299
|
+
const { data } = rowItem;
|
|
300
|
+
|
|
301
|
+
// 1. Resolve or create category
|
|
302
|
+
let categoryId = rowItem.resolvedCategoryId;
|
|
303
|
+
if (!categoryId || rowItem.willCreateCategory) {
|
|
304
|
+
const catKey = data.categoryName.toLowerCase().trim();
|
|
305
|
+
if (createdCategories.has(catKey)) {
|
|
306
|
+
categoryId = createdCategories.get(catKey)!;
|
|
307
|
+
} else {
|
|
308
|
+
const [newCat] = await tx
|
|
309
|
+
.insert(deviceCategories)
|
|
310
|
+
.values({
|
|
311
|
+
organizationId: org.id,
|
|
312
|
+
name: data.categoryName,
|
|
313
|
+
code: data.categoryName.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 10) || 'CAT',
|
|
314
|
+
riskClassification: data.riskClassification as any,
|
|
315
|
+
criticalityLevel: data.criticalityLevel as any,
|
|
316
|
+
})
|
|
317
|
+
.returning();
|
|
318
|
+
categoryId = newCat.id;
|
|
319
|
+
createdCategories.set(catKey, newCat.id);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 2. Resolve or create manufacturer
|
|
324
|
+
let manufacturerId = rowItem.resolvedManufacturerId;
|
|
325
|
+
if (data.manufacturerName && (!manufacturerId || rowItem.willCreateManufacturer)) {
|
|
326
|
+
const mfrKey = data.manufacturerName.toLowerCase().trim();
|
|
327
|
+
if (createdManufacturers.has(mfrKey)) {
|
|
328
|
+
manufacturerId = createdManufacturers.get(mfrKey)!;
|
|
329
|
+
} else {
|
|
330
|
+
const [newMfr] = await tx
|
|
331
|
+
.insert(manufacturers)
|
|
332
|
+
.values({
|
|
333
|
+
organizationId: org.id,
|
|
334
|
+
name: data.manufacturerName,
|
|
335
|
+
code: data.manufacturerName.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 10) || 'MFR',
|
|
336
|
+
})
|
|
337
|
+
.returning();
|
|
338
|
+
manufacturerId = newMfr.id;
|
|
339
|
+
createdManufacturers.set(mfrKey, newMfr.id);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 3. Insert Device
|
|
344
|
+
await tx.insert(devices).values({
|
|
345
|
+
organizationId: org.id,
|
|
346
|
+
hospitalId: defaultHospital.id,
|
|
347
|
+
departmentId: rowItem.resolvedDepartmentId!,
|
|
348
|
+
internalCode: data.assetNumber,
|
|
349
|
+
assetNumber: data.assetNumber,
|
|
350
|
+
name: data.name,
|
|
351
|
+
serialNumber: data.serialNumber || null,
|
|
352
|
+
modelNameFree: data.model || null,
|
|
353
|
+
deviceCategoryId: categoryId!,
|
|
354
|
+
manufacturerId: manufacturerId || null,
|
|
355
|
+
exactLocationDescription: data.locationDescription || null,
|
|
356
|
+
criticalityLevel: data.criticalityLevel as any,
|
|
357
|
+
riskClassification: data.riskClassification as any,
|
|
358
|
+
purchaseDate: data.purchaseDate || null,
|
|
359
|
+
purchaseCost: data.purchaseCost ? String(data.purchaseCost) : null,
|
|
360
|
+
installationDate: data.installationDate || null,
|
|
361
|
+
warrantyStartDate: data.warrantyStartDate || null,
|
|
362
|
+
warrantyEndDate: data.warrantyEndDate || null,
|
|
363
|
+
currentStatusCode: 'operational',
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
importedCount++;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Log immutable audit trail entry
|
|
370
|
+
await createAuditLog(tx, {
|
|
371
|
+
action: 'CREATE',
|
|
372
|
+
entityType: 'devices',
|
|
373
|
+
entityId: org.id,
|
|
374
|
+
actorUserId: session.id,
|
|
375
|
+
organizationId: org.id,
|
|
376
|
+
details: {
|
|
377
|
+
importedCount,
|
|
378
|
+
autoCreatedCategories: createdCategories.size,
|
|
379
|
+
autoCreatedManufacturers: createdManufacturers.size,
|
|
380
|
+
},
|
|
381
|
+
changeReason: `Bulk imported ${importedCount} medical equipment records from CSV`,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
return { success: true, importedCount };
|
|
385
|
+
});
|
|
386
|
+
} catch (error: any) {
|
|
387
|
+
return { success: false, importedCount: 0, error: error.message };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Returns entity counts for the export center.
|
|
393
|
+
*/
|
|
394
|
+
export async function getExportSummary() {
|
|
395
|
+
await requireAuth();
|
|
396
|
+
await requireRole('SYS_ADMIN', 'ORG_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG');
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
const [devicesCount, departmentsCount, categoriesCount, manufacturersCount, ticketsCount, auditLogsCount, roundsCount] =
|
|
400
|
+
await Promise.all([
|
|
401
|
+
db.select({ count: sql<number>`count(*)` }).from(devices),
|
|
402
|
+
db.select({ count: sql<number>`count(*)` }).from(departments),
|
|
403
|
+
db.select({ count: sql<number>`count(*)` }).from(deviceCategories),
|
|
404
|
+
db.select({ count: sql<number>`count(*)` }).from(manufacturers),
|
|
405
|
+
db.select({ count: sql<number>`count(*)` }).from(serviceTickets),
|
|
406
|
+
db.select({ count: sql<number>`count(*)` }).from(auditLogs),
|
|
407
|
+
db.select({ count: sql<number>`count(*)` }).from(engineerRounds),
|
|
408
|
+
]);
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
success: true,
|
|
412
|
+
data: {
|
|
413
|
+
devices: Number(devicesCount[0]?.count || 0),
|
|
414
|
+
departments: Number(departmentsCount[0]?.count || 0),
|
|
415
|
+
categories: Number(categoriesCount[0]?.count || 0),
|
|
416
|
+
manufacturers: Number(manufacturersCount[0]?.count || 0),
|
|
417
|
+
tickets: Number(ticketsCount[0]?.count || 0),
|
|
418
|
+
auditLogs: Number(auditLogsCount[0]?.count || 0),
|
|
419
|
+
rounds: Number(roundsCount[0]?.count || 0),
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
} catch (error: any) {
|
|
423
|
+
return { success: false, error: error.message };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* High-friction purge and reset tool with password verification and audit trace.
|
|
429
|
+
*/
|
|
430
|
+
export async function purgeData(payload: PurgeDataPayload): Promise<{
|
|
431
|
+
success: boolean;
|
|
432
|
+
message?: string;
|
|
433
|
+
error?: string;
|
|
434
|
+
}> {
|
|
435
|
+
const session = await requireAuth();
|
|
436
|
+
// Strictly restricted to SYS_ADMIN
|
|
437
|
+
await requireRole('SYS_ADMIN');
|
|
438
|
+
|
|
439
|
+
const validated = purgeDataSchema.safeParse(payload);
|
|
440
|
+
if (!validated.success) {
|
|
441
|
+
return { success: false, error: validated.error.errors[0]?.message || 'Invalid parameters' };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const { scope, confirmText, password, reason } = validated.data;
|
|
445
|
+
|
|
446
|
+
// 1. Verify confirmation phrase (flexible: case-insensitive, accepts underscores or spaces)
|
|
447
|
+
const normalizePhrase = (s: string) => s.trim().toUpperCase().replace(/[_-\s]+/g, ' ');
|
|
448
|
+
const isMatch = scope === 'test_transactions'
|
|
449
|
+
? normalizePhrase(confirmText) === 'CLEAR TEST DATA' || normalizePhrase(confirmText) === 'PURGE TEST DATA'
|
|
450
|
+
: normalizePhrase(confirmText) === 'RESET ALL DATA';
|
|
451
|
+
|
|
452
|
+
if (!isMatch) {
|
|
453
|
+
const expected = scope === 'test_transactions' ? 'CLEAR TEST DATA' : 'RESET ALL DATA';
|
|
454
|
+
return {
|
|
455
|
+
success: false,
|
|
456
|
+
error: `Confirmation phrase must match "${expected}".`,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 2. Re-authenticate admin password (21 CFR Part 11 electronic signature standard)
|
|
461
|
+
const adminUser = await db.query.users.findFirst({
|
|
462
|
+
where: eq(users.id, session.id),
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
if (!adminUser || !adminUser.passwordHash) {
|
|
466
|
+
return { success: false, error: 'User account verification failed.' };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const isPasswordValid = await verifyPassword(password, adminUser.passwordHash);
|
|
470
|
+
if (!isPasswordValid) {
|
|
471
|
+
return { success: false, error: 'Incorrect administrator password. Re-authentication rejected.' };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
return await db.transaction(async (tx) => {
|
|
476
|
+
// Delete in proper reverse foreign-key dependency order
|
|
477
|
+
await tx.delete(maintenanceParts);
|
|
478
|
+
await tx.delete(maintenanceCosts);
|
|
479
|
+
await tx.delete(maintenanceRecords);
|
|
480
|
+
await tx.delete(maintenanceTasks);
|
|
481
|
+
await tx.delete(maintenanceScheduleOccurrences);
|
|
482
|
+
|
|
483
|
+
if (scope === 'test_transactions') {
|
|
484
|
+
await tx.delete(serviceTickets);
|
|
485
|
+
await tx.delete(roundVisitNotes);
|
|
486
|
+
await tx.delete(roundDepartmentVisits);
|
|
487
|
+
await tx.delete(engineerRounds);
|
|
488
|
+
|
|
489
|
+
await createAuditLog(tx, {
|
|
490
|
+
action: 'PURGE',
|
|
491
|
+
entityType: 'system',
|
|
492
|
+
entityId: session.id,
|
|
493
|
+
actorUserId: session.id,
|
|
494
|
+
changeReason: reason || 'Purged transactional test tickets, maintenance logs, and engineer rounds prior to deployment',
|
|
495
|
+
details: { scope: 'test_transactions', executedAt: new Date().toISOString() },
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
success: true,
|
|
500
|
+
message: 'Transactional test data (tickets, work orders, maintenance logs, engineer rounds) purged successfully.',
|
|
501
|
+
};
|
|
502
|
+
} else {
|
|
503
|
+
// Factory Reset: Purge plans, tickets, rounds, devices, and logs
|
|
504
|
+
await tx.delete(maintenancePlans);
|
|
505
|
+
await tx.delete(serviceTickets);
|
|
506
|
+
await tx.delete(roundVisitNotes);
|
|
507
|
+
await tx.delete(roundDepartmentVisits);
|
|
508
|
+
await tx.delete(engineerRounds);
|
|
509
|
+
await tx.delete(devices);
|
|
510
|
+
await tx.delete(auditLogs);
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
success: true,
|
|
514
|
+
message: 'Factory reset completed. System restored to baseline state.',
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
} catch (error: any) {
|
|
519
|
+
return { success: false, error: error.message };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Validates a batch of department records from raw CSV content.
|
|
525
|
+
*/
|
|
526
|
+
export async function validateDepartmentImport(csvContent: string): Promise<{
|
|
527
|
+
success: boolean;
|
|
528
|
+
totalRows: number;
|
|
529
|
+
validCount: number;
|
|
530
|
+
warningCount: number;
|
|
531
|
+
errorCount: number;
|
|
532
|
+
rows: DepartmentImportValidationResult[];
|
|
533
|
+
error?: string;
|
|
534
|
+
}> {
|
|
535
|
+
await requireAuth();
|
|
536
|
+
await requireRole('SYS_ADMIN', 'ORG_ADMIN', 'BIOMED_MGR');
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
const rawRecords = parseCSV(csvContent);
|
|
540
|
+
if (rawRecords.length === 0) {
|
|
541
|
+
return {
|
|
542
|
+
success: false,
|
|
543
|
+
totalRows: 0,
|
|
544
|
+
validCount: 0,
|
|
545
|
+
warningCount: 0,
|
|
546
|
+
errorCount: 0,
|
|
547
|
+
rows: [],
|
|
548
|
+
error: 'No valid data rows found in CSV. Please ensure the file includes column headers.',
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const [existingDepartments, existingHospitals] = await Promise.all([
|
|
553
|
+
db.query.departments.findMany({
|
|
554
|
+
columns: { code: true },
|
|
555
|
+
}),
|
|
556
|
+
db.query.hospitals.findMany(),
|
|
557
|
+
]);
|
|
558
|
+
|
|
559
|
+
const existingCodes = new Set(existingDepartments.map((d) => d.code.toLowerCase().trim()));
|
|
560
|
+
const hospitalMap = new Map(existingHospitals.map((h) => [h.name.toLowerCase().trim(), h]));
|
|
561
|
+
|
|
562
|
+
const results: DepartmentImportValidationResult[] = [];
|
|
563
|
+
let validCount = 0;
|
|
564
|
+
let warningCount = 0;
|
|
565
|
+
let errorCount = 0;
|
|
566
|
+
|
|
567
|
+
const currentBatchCodes = new Set<string>();
|
|
568
|
+
|
|
569
|
+
for (let i = 0; i < rawRecords.length; i++) {
|
|
570
|
+
const row = rawRecords[i];
|
|
571
|
+
const rowNumber = i + 2;
|
|
572
|
+
|
|
573
|
+
const mappedRow = {
|
|
574
|
+
name: row.name || row.department_name || '',
|
|
575
|
+
code: row.code || row.department_code || '',
|
|
576
|
+
departmentType: row.department_type || row.type || '',
|
|
577
|
+
hospitalName: row.hospital_name || row.hospital || '',
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const parseResult = departmentImportRowSchema.safeParse(mappedRow);
|
|
581
|
+
const messages: string[] = [];
|
|
582
|
+
let status: 'valid' | 'warning' | 'error' = 'valid';
|
|
583
|
+
|
|
584
|
+
if (!parseResult.success) {
|
|
585
|
+
status = 'error';
|
|
586
|
+
parseResult.error.errors.forEach((err) => messages.push(err.message));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const validData = parseResult.success ? parseResult.data : mappedRow;
|
|
590
|
+
const cleanCode = validData.code.toLowerCase().trim();
|
|
591
|
+
|
|
592
|
+
if (cleanCode) {
|
|
593
|
+
if (existingCodes.has(cleanCode)) {
|
|
594
|
+
status = 'error';
|
|
595
|
+
messages.push(`Department code "${validData.code}" already exists in the database.`);
|
|
596
|
+
} else if (currentBatchCodes.has(cleanCode)) {
|
|
597
|
+
status = 'error';
|
|
598
|
+
messages.push(`Duplicate department code "${validData.code}" within this CSV file.`);
|
|
599
|
+
} else {
|
|
600
|
+
currentBatchCodes.add(cleanCode);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
let resolvedHospitalId: string | undefined;
|
|
605
|
+
const cleanHosp = validData.hospitalName.toLowerCase().trim();
|
|
606
|
+
const matchedHosp = hospitalMap.get(cleanHosp);
|
|
607
|
+
|
|
608
|
+
if (matchedHosp) {
|
|
609
|
+
resolvedHospitalId = matchedHosp.id;
|
|
610
|
+
} else {
|
|
611
|
+
status = 'error';
|
|
612
|
+
messages.push(`Hospital "${validData.hospitalName}" not found.`);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (!validData.departmentType && status !== 'error') {
|
|
616
|
+
status = 'warning';
|
|
617
|
+
messages.push('Department type unspecified; defaulted to "general".');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (status === 'error') {
|
|
621
|
+
errorCount++;
|
|
622
|
+
} else if (status === 'warning') {
|
|
623
|
+
warningCount++;
|
|
624
|
+
} else {
|
|
625
|
+
validCount++;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
results.push({
|
|
629
|
+
rowNumber,
|
|
630
|
+
data: validData as any,
|
|
631
|
+
status,
|
|
632
|
+
messages,
|
|
633
|
+
resolvedHospitalId,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
success: true,
|
|
639
|
+
totalRows: rawRecords.length,
|
|
640
|
+
validCount,
|
|
641
|
+
warningCount,
|
|
642
|
+
errorCount,
|
|
643
|
+
rows: results,
|
|
644
|
+
};
|
|
645
|
+
} catch (error: any) {
|
|
646
|
+
return {
|
|
647
|
+
success: false,
|
|
648
|
+
totalRows: 0,
|
|
649
|
+
validCount: 0,
|
|
650
|
+
warningCount: 0,
|
|
651
|
+
errorCount: 0,
|
|
652
|
+
rows: [],
|
|
653
|
+
error: error.message || 'Failed to parse and validate CSV file.',
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Commits pre-validated department records to the database.
|
|
660
|
+
*/
|
|
661
|
+
export async function executeDepartmentImport(rows: DepartmentImportValidationResult[]): Promise<{
|
|
662
|
+
success: boolean;
|
|
663
|
+
importedCount: number;
|
|
664
|
+
error?: string;
|
|
665
|
+
}> {
|
|
666
|
+
const session = await requireAuth();
|
|
667
|
+
await requireRole('SYS_ADMIN', 'ORG_ADMIN', 'BIOMED_MGR');
|
|
668
|
+
|
|
669
|
+
const importableRows = rows.filter((r) => r.status === 'valid' || r.status === 'warning');
|
|
670
|
+
if (importableRows.length === 0) {
|
|
671
|
+
return { success: false, importedCount: 0, error: 'No valid rows available to import.' };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let org = await db.query.organizations.findFirst();
|
|
675
|
+
if (session.organizationId) {
|
|
676
|
+
const foundOrg = await db.query.organizations.findFirst({
|
|
677
|
+
where: eq(organizations.id, session.organizationId),
|
|
678
|
+
});
|
|
679
|
+
if (foundOrg) org = foundOrg;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (!org) {
|
|
683
|
+
return { success: false, importedCount: 0, error: 'Organization not found.' };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
try {
|
|
687
|
+
return await db.transaction(async (tx) => {
|
|
688
|
+
let importedCount = 0;
|
|
689
|
+
|
|
690
|
+
for (const rowItem of importableRows) {
|
|
691
|
+
const { data } = rowItem;
|
|
692
|
+
|
|
693
|
+
await tx.insert(departments).values({
|
|
694
|
+
organizationId: org.id,
|
|
695
|
+
hospitalId: rowItem.resolvedHospitalId!,
|
|
696
|
+
name: data.name,
|
|
697
|
+
code: data.code,
|
|
698
|
+
departmentType: data.departmentType || 'general',
|
|
699
|
+
status: 'active',
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
importedCount++;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
await createAuditLog(tx, {
|
|
706
|
+
action: 'CREATE',
|
|
707
|
+
entityType: 'departments',
|
|
708
|
+
entityId: org.id,
|
|
709
|
+
actorUserId: session.id,
|
|
710
|
+
organizationId: org.id,
|
|
711
|
+
details: {
|
|
712
|
+
importedCount,
|
|
713
|
+
},
|
|
714
|
+
changeReason: `Bulk imported ${importedCount} departments from CSV`,
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
return { success: true, importedCount };
|
|
718
|
+
});
|
|
719
|
+
} catch (error: any) {
|
|
720
|
+
return { success: false, importedCount: 0, error: error.message };
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|