From a71d7d49ded4c173721492e4de24a1a8bded4b21 Mon Sep 17 00:00:00 2001 From: Mark Glose Date: Wed, 27 May 2026 18:38:39 -0500 Subject: [PATCH 01/14] [VW-259] Basic Hackathon 2 wiring --- prisma/schema.prisma | 127 ++++ prisma/seed.ts | 543 +++++++++++++++++ .../(rest)/settings/departments/page.tsx | 30 + .../(rest)/settings/tag-colors/page.tsx | 32 + .../(rest)/tracking/[ticketId]/page.tsx | 32 + src/app/(dashboard)/(rest)/tracking/page.tsx | 18 + src/components/app-sidebar.tsx | 6 + src/components/ui/data-table.tsx | 147 +++-- .../departments/components/departments.tsx | 300 ++++++++++ .../departments/hooks/use-departments.ts | 64 ++ src/features/departments/server/prefetch.ts | 5 + src/features/departments/server/routers.ts | 65 ++ .../settings/components/settings-layout.tsx | 22 +- .../tag-colors/components/color-picker.tsx | 78 +++ .../tag-colors/components/tag-colors.tsx | 163 +++++ src/features/tag-colors/context.tsx | 31 + .../tag-colors/hooks/use-tag-colors.ts | 33 ++ src/features/tag-colors/palette.ts | 99 ++++ src/features/tag-colors/server/prefetch.ts | 5 + src/features/tag-colors/server/routers.ts | 27 + src/features/tracking/components/columns.tsx | 257 ++++++++ .../tracking/components/ticket-detail.tsx | 559 ++++++++++++++++++ src/features/tracking/components/tracking.tsx | 125 ++++ .../tracking/hooks/use-tracking-params.ts | 8 + src/features/tracking/hooks/use-tracking.ts | 39 ++ src/features/tracking/params.ts | 18 + src/features/tracking/server/params-loader.ts | 4 + src/features/tracking/server/prefetch.ts | 14 + src/features/tracking/server/routers.ts | 179 ++++++ src/features/tracking/types.ts | 104 ++++ src/trpc/routers/_app.ts | 6 + 31 files changed, 3096 insertions(+), 44 deletions(-) create mode 100644 src/app/(dashboard)/(rest)/settings/departments/page.tsx create mode 100644 src/app/(dashboard)/(rest)/settings/tag-colors/page.tsx create mode 100644 src/app/(dashboard)/(rest)/tracking/[ticketId]/page.tsx create mode 100644 src/app/(dashboard)/(rest)/tracking/page.tsx create mode 100644 src/features/departments/components/departments.tsx create mode 100644 src/features/departments/hooks/use-departments.ts create mode 100644 src/features/departments/server/prefetch.ts create mode 100644 src/features/departments/server/routers.ts create mode 100644 src/features/tag-colors/components/color-picker.tsx create mode 100644 src/features/tag-colors/components/tag-colors.tsx create mode 100644 src/features/tag-colors/context.tsx create mode 100644 src/features/tag-colors/hooks/use-tag-colors.ts create mode 100644 src/features/tag-colors/palette.ts create mode 100644 src/features/tag-colors/server/prefetch.ts create mode 100644 src/features/tag-colors/server/routers.ts create mode 100644 src/features/tracking/components/columns.tsx create mode 100644 src/features/tracking/components/ticket-detail.tsx create mode 100644 src/features/tracking/components/tracking.tsx create mode 100644 src/features/tracking/hooks/use-tracking-params.ts create mode 100644 src/features/tracking/hooks/use-tracking.ts create mode 100644 src/features/tracking/params.ts create mode 100644 src/features/tracking/server/params-loader.ts create mode 100644 src/features/tracking/server/prefetch.ts create mode 100644 src/features/tracking/server/routers.ts create mode 100644 src/features/tracking/types.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 84a1978d..dc54d1d4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,7 +42,14 @@ model User { threads ChatThread[] memories Memory[] + departmentId String? + department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull) + workOrderTicketsCreated WorkOrderTicket[] @relation("WorkOrderTicketCreator") + workOrderTicketsAssigned WorkOrderTicket[] @relation("WorkOrderTicketAssignee") + ticketComments TicketComment[] + @@unique([email]) + @@index([departmentId]) @@map("user") } @@ -119,6 +126,8 @@ model Workflow { userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + sourceWorkOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketSourceWorkflow") } enum NodeType { @@ -244,6 +253,7 @@ model Asset { issues Issue[] deviceGroupHistories DeviceGroupHistory[] externalMappings ExternalAssetMapping[] + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketAssets") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -470,6 +480,7 @@ model Vulnerability { remediations Remediation[] issues Issue[] + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketVulns") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -501,6 +512,7 @@ model Issue { vulnerability Vulnerability @relation(fields: [vulnerabilityId], references: [id], onDelete: Cascade) issueRemediations IssueRemediation[] + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketIssues") status IssueStatus @default(ACTIVE) @@ -524,6 +536,7 @@ model Remediation { vulnerability Vulnerability? @relation(fields: [vulnerabilityId], references: [id], onDelete: SetNull) issueRemediations IssueRemediation[] + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketRemediations") userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -742,6 +755,7 @@ model Advisory { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt externalMappings ExternalAdvisoryMapping[] + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketAdvisories") @@index([userId]) @@map("advisory") @@ -813,3 +827,116 @@ model Memory { @@index([userId]) } + +enum TicketStatus { + TO_DO + IN_PROGRESS + REQUIRES_APPROVAL + DONE +} + +enum TicketCategory { + PATCH + CONFIG_CHANGE + VULN_REMEDIATION + ADVISORY_RESPONSE + CLINICAL_REVIEW + FIRMWARE_UPDATE + NETWORK_REMEDIATION + NEW_ASSET_PROCUREMENT + OTHER +} + +enum TicketSource { + WORKFLOW + MANUAL + WEBHOOK + API +} + +model Department { + id String @id @default(cuid()) + name String @unique + description String? @db.Text + color String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + users User[] + tickets WorkOrderTicket[] + + @@map("department") +} + +model CategoryColor { + id String @id @default(cuid()) + category TicketCategory @unique + color String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("category_color") +} + +model WorkOrderTicket { + id String @id @default(cuid()) + summary String + description String? @db.Text + + status TicketStatus @default(TO_DO) + category TicketCategory @default(OTHER) + + lifeSafety Boolean @default(false) + + source TicketSource @default(MANUAL) + sourceWorkflowId String? + sourceWorkflow Workflow? @relation("WorkOrderTicketSourceWorkflow", fields: [sourceWorkflowId], references: [id], onDelete: SetNull) + + departmentId String? + department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull) + + parentId String? + parent WorkOrderTicket? @relation("WorkOrderTicketChildren", fields: [parentId], references: [id], onDelete: Cascade) + children WorkOrderTicket[] @relation("WorkOrderTicketChildren") + + creatorId String + creator User @relation("WorkOrderTicketCreator", fields: [creatorId], references: [id], onDelete: Cascade) + assigneeId String? + assignee User? @relation("WorkOrderTicketAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) + + scheduledAt DateTime? + + issues Issue[] @relation("WorkOrderTicketIssues") + vulnerabilities Vulnerability[] @relation("WorkOrderTicketVulns") + remediations Remediation[] @relation("WorkOrderTicketRemediations") + advisories Advisory[] @relation("WorkOrderTicketAdvisories") + assets Asset[] @relation("WorkOrderTicketAssets") + + comments TicketComment[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) + @@index([departmentId]) + @@index([assigneeId]) + @@index([creatorId]) + @@index([sourceWorkflowId]) + @@index([parentId]) + @@map("work_order_ticket") +} + +model TicketComment { + id String @id @default(cuid()) + ticketId String + ticket WorkOrderTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + authorId String + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + body String @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([ticketId, createdAt]) + @@index([authorId]) + @@map("ticket_comment") +} \ No newline at end of file diff --git a/prisma/seed.ts b/prisma/seed.ts index fc7278bb..0cf0c697 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -4,6 +4,9 @@ import { type AssetStatus, Priority, Severity, + TicketCategory, + TicketSource, + TicketStatus, } from "@/generated/prisma"; import prisma from "@/lib/db"; @@ -623,6 +626,44 @@ const SAMPLE_VULNERABILITIES = [ impact: "Compromise of the VPN gateway severs remote radiologist connectivity, disabling after-hours imaging coverage and potentially delaying critical results. Compromise of the perimeter firewall allows the attacker to modify security policy, intercept all DICOM/HL7 traffic, and pivot freely into clinical VLANs containing imaging devices, PACS, and patient monitoring infrastructure.", }, + // ── CVE-2021-12345: Baxter Sigma Spectrum Buffer Overflow (High) ──────────── + // Hypothetical CVE used for seed data — drives the "Update Baxter Infusion + // Pumps Firmware" change ticket and creates Issue records for all 23 pumps. + { + cveId: "CVE-2021-12345", + severity: Severity.High, + cvssScore: 8.1, + epss: 0.04, + inKEV: false, + priority: Priority.High, + sarif: { + version: "2.1.0", + runs: [ + { + tool: { driver: { name: "Medical Device Scanner" } }, + results: [ + { + ruleId: "CVE-2021-12345", + level: "warning", + message: { + text: "Baxter Sigma Spectrum infusion pump firmware contains a buffer overflow in the wireless management interface allowing remote code execution from the same wireless segment", + }, + }, + ], + }, + ], + }, + cpes: ["cpe:2.3:h:baxter:sigma_spectrum:-:*:*:*:*:*:*:*"], + exploitUri: "https://nvd.nist.gov/vuln/detail/CVE-2021-12345", + upstreamApi: + "https://www.baxter.com/healthcare-professionals/service-and-support", + description: + "Buffer overflow in the wireless management interface of Baxter Sigma Spectrum infusion pumps running firmware versions prior to 8.x allows an attacker on the same wireless segment to execute arbitrary code or crash the device via crafted management packets. All 23 Sigma Spectrum pumps on WARD-VLAN-20 are running the vulnerable firmware (v1.2.3).", + narrative: + "The Baxter Sigma Spectrum pump fleet on the medical-surgical floor is running firmware v1.2.3, which contains a buffer overflow in the wireless management interface. An attacker with access to WARD-VLAN-20 can send crafted management packets to crash a pump or execute code in the pump's control context. Baxter has issued an updated firmware release that addresses the vulnerability, but each pump must be taken out of service, updated, and clinically validated before being returned to patient use.", + impact: + "A successful exploit could cause a pump to crash mid-infusion, interrupting medication delivery to a patient. In the worst case, code execution could allow an attacker to alter infusion parameters (rate, volume, dose) — a direct patient-safety risk for any patient on continuous IV medication. With all 23 pumps running the vulnerable firmware, a worm-style attack could disable the entire medical-surgical infusion fleet simultaneously.", + }, ]; const SAMPLE_MEMORIES = [ @@ -683,9 +724,366 @@ const SAMPLE_REMEDIATIONS = [ }, ]; +const SAMPLE_DEPARTMENTS = [ + { + name: "Radiology", + description: + "Imaging department — CT, PACS, ultrasound, X-ray, reading rooms.", + color: "purple", + }, + { + name: "Emergency Department", + description: + "ED clinical operations, including the bedside image viewer.", + color: "red", + }, + { + name: "Biomed", + description: + "Medical device lifecycle — patient monitors, infusion pumps, validation.", + color: "orange", + }, + { + name: "Biotech", + description: "Biotechnology research and lab operations.", + color: "yellow", + }, + { + name: "Nursing", + description: "Ward operations on the medical-surgical floor.", + color: "pink", + }, + { + name: "IT", + description: + "Network, firewall, VPN, identity, and security operations.", + color: "blue", + }, + { + name: "Procurement", + description: "Capital equipment sourcing and vendor management.", + color: "blue", + }, + { + name: "Administration", + description: "Hospital leadership and compliance oversight.", + color: "slate", + }, +]; + +const SAMPLE_CATEGORY_COLORS: { category: TicketCategory; color: string }[] = [ + { category: TicketCategory.PATCH, color: "blue" }, + { category: TicketCategory.CONFIG_CHANGE, color: "purple" }, + { category: TicketCategory.VULN_REMEDIATION, color: "red" }, + { category: TicketCategory.ADVISORY_RESPONSE, color: "amber" }, + { category: TicketCategory.CLINICAL_REVIEW, color: "pink" }, + { category: TicketCategory.FIRMWARE_UPDATE, color: "orange" }, + { category: TicketCategory.NETWORK_REMEDIATION, color: "blue" }, + { category: TicketCategory.NEW_ASSET_PROCUREMENT, color: "purple" }, + { category: TicketCategory.OTHER, color: "slate" }, +]; + +const SEED_USER_DEPARTMENT = "IT"; + +type SampleTicket = { + summary: string; + description?: string; + status: TicketStatus; + category: TicketCategory; + department: string; + lifeSafety?: boolean; + source: TicketSource; + sourceWorkflowName?: string; + scheduledAt?: Date; + linkedCveIds?: string[]; + linkedAssetIds?: string[]; + comments?: string[]; +}; + +type SampleParentTicket = SampleTicket & { children?: SampleTicket[] }; + +// Tickets generated against the seed vulnerabilities/assets/workflows above. +// Scheduled dates are relative to seed run; relative offsets are computed below. +const dayMs = 24 * 60 * 60 * 1000; +const now = Date.now(); +const inDays = (n: number) => new Date(now + n * dayMs); + +const SAMPLE_CHANGE_TICKETS: SampleParentTicket[] = [ + { + summary: + "Update Baxter Infusion Pumps Firmware", + description: + "Baxter Sigma Spectrum infusion pumps are running firmware version 1.2.3, which is vulnerable to CVE-2021-12345 (hypothetical buffer overflow). Coordinate with Biomed to schedule firmware updates for all 23 pumps, ensuring validation of clinical functionality post-update.", + status: TicketStatus.IN_PROGRESS, + category: TicketCategory.FIRMWARE_UPDATE, + department: "Biomed", + source: TicketSource.MANUAL, + linkedCveIds: ["CVE-2021-12345"], + linkedAssetIds: Array.from({ length: 23 }, (_, i) => `rad-pump-${String(i + 1).padStart(3, "0")}`), + comments: [ + "Firmware update available from Baxter support portal.", + ], + children: [ + { + summary: "Update firmware on ICU pumps (4 devices)", + description: + "Schedule update for pumps in ICU first, coordinate with nursing to minimize disruption.", + status: TicketStatus.DONE, + category: TicketCategory.FIRMWARE_UPDATE, + department: "Biomed", + source: TicketSource.MANUAL, + linkedCveIds: ["CVE-2021-12345"], + linkedAssetIds: ["rad-pump-001", "rad-pump-002", "rad-pump-003", "rad-pump-004"], + comments: [ + "Completed on 2026-05-15; validated pump functionality post-update.", + ], + }, + { + summary: "Update firmware on ER pumps (3 devices)", + description: + "Schedule update for pumps in ER next, coordinate with nursing to minimize disruption.", + status: TicketStatus.IN_PROGRESS, + category: TicketCategory.FIRMWARE_UPDATE, + department: "Biomed", + source: TicketSource.MANUAL, + linkedCveIds: ["CVE-2021-12345"], + linkedAssetIds: ["rad-pump-005", "rad-pump-006", "rad-pump-007"], + comments: [ + "Pending Biomed review and scheduling.", + ], + }, + { + summary: "Update firmware on Surgery pumps (3 devices)", + description: + "Schedule update for pumps in Surgery next, coordinate with nursing to minimize disruption.", + status: TicketStatus.TO_DO, + category: TicketCategory.FIRMWARE_UPDATE, + department: "Biomed", + source: TicketSource.MANUAL, + linkedCveIds: ["CVE-2021-12345"], + linkedAssetIds: ["rad-pump-008", "rad-pump-009", "rad-pump-010"], + } + ] + }, + { + summary: + "Remediate EternalBlue (CVE-2017-0144) across EOL Windows imaging hosts", + description: + "Umbrella ticket covering SMBv1/MS17-010 mitigation on every Windows 7 / Server 2008 R2 host in the imaging chain. Tracks per-host workstreams and the network-level compensating controls.", + status: TicketStatus.IN_PROGRESS, + category: TicketCategory.VULN_REMEDIATION, + department: "Radiology", + lifeSafety: true, + source: TicketSource.WORKFLOW, + sourceWorkflowName: "Emergency CT: Acute Stroke / Trauma Protocol", + linkedCveIds: ["CVE-2017-0144"], + linkedAssetIds: [ + "rad-ws-001", + "rad-pacs-001", + "rad-rws-001", + "rad-rws-002", + "rad-ed-001", + ], + comments: [ + "Tracking five hosts + two compensating controls. Final verify lands once PACS patch is approved.", + ], + children: [ + { + summary: "Disable SMBv1 on CT Acquisition Workstation (rad-ws-001)", + description: + "Mitigates EternalBlue on the EOL Windows 7 host. Coordinate with Radiology — workstation is on the Emergency CT path.", + status: TicketStatus.TO_DO, + category: TicketCategory.CONFIG_CHANGE, + department: "Radiology", + lifeSafety: true, + source: TicketSource.WORKFLOW, + sourceWorkflowName: "Emergency CT: Acute Stroke / Trauma Protocol", + linkedCveIds: ["CVE-2017-0144"], + linkedAssetIds: ["rad-ws-001"], + comments: [ + "Life-safety path: confirm fallback procedure before rollout.", + "Vendor compat check pending with GE for Advantage Workstation 4.6.", + ], + }, + { + summary: "Patch PACS Server EternalBlue (rad-pacs-001)", + description: + "PACS server is on EOL Windows Server 2008 R2. Apply MS17-010 via extended support or disable SMBv1 + block 445 at the switch. Affects the entire imaging workflow.", + status: TicketStatus.REQUIRES_APPROVAL, + category: TicketCategory.VULN_REMEDIATION, + department: "Radiology", + lifeSafety: true, + source: TicketSource.WORKFLOW, + sourceWorkflowName: "Remote Radiology — After-Hours Imaging Coverage", + scheduledAt: inDays(10), + linkedCveIds: ["CVE-2017-0144"], + linkedAssetIds: ["rad-pacs-001"], + comments: [ + "Awaiting CMO sign-off — full imaging chain outage if this goes sideways.", + ], + }, + { + summary: "Disable SMBv1 on Radiology Diagnostic Workstations", + description: + "Apply SMBv1 disablement to rad-rws-001 and rad-rws-002 in tandem so radiologist coverage isn't interrupted.", + status: TicketStatus.IN_PROGRESS, + category: TicketCategory.CONFIG_CHANGE, + department: "Radiology", + source: TicketSource.MANUAL, + scheduledAt: inDays(1), + linkedCveIds: ["CVE-2017-0144"], + linkedAssetIds: ["rad-rws-001", "rad-rws-002"], + comments: ["rws-001 done; rws-002 scheduled for tonight."], + }, + { + summary: "Block TCP/445 at imaging switch (rad-sw-001)", + description: + "Network-level compensating control for EternalBlue on EOL Windows hosts. ACL deployed on imaging and PACS VLANs.", + status: TicketStatus.DONE, + category: TicketCategory.CONFIG_CHANGE, + department: "IT", + source: TicketSource.MANUAL, + scheduledAt: inDays(-9), + linkedCveIds: ["CVE-2017-0144"], + linkedAssetIds: ["rad-sw-001"], + comments: ["Work order ticket CHG-2207 closed; pending verification."], + }, + { + summary: "Verify DICOM VLAN ACL enforcement", + description: + "Validate post-deployment that only DICOM (104/TCP) is permitted between imaging devices and PACS.", + status: TicketStatus.DONE, + category: TicketCategory.CONFIG_CHANGE, + department: "IT", + source: TicketSource.MANUAL, + scheduledAt: inDays(-6), + linkedCveIds: ["CVE-2020-25175"], + linkedAssetIds: ["rad-pacs-001", "rad-sw-001"], + comments: ["Validated via packet capture on 2026-05-21."], + }, + ], + }, + { + summary: "GE Imaging Device Hardening (CVE-2020-25175)", + description: + "Umbrella ticket for credential-exposure mitigations across GE BrightSpeed CT, LOGIQ e ultrasounds, and Optima XR200amx per CISA ICSMA-20-343-01.", + status: TicketStatus.TO_DO, + category: TicketCategory.VULN_REMEDIATION, + department: "Radiology", + source: TicketSource.WORKFLOW, + sourceWorkflowName: "Emergency CT: Acute Stroke / Trauma Protocol", + linkedCveIds: ["CVE-2020-25175"], + linkedAssetIds: ["rad-ct-001", "rad-us-001", "rad-us-002", "rad-xr-001"], + comments: [ + "Proposed by workflow after AI.PatchRead flagged credential exposure on the DICOM VLAN.", + ], + children: [ + { + summary: "Isolate GE imaging devices to a dedicated DICOM VLAN", + description: + "Per CISA ICSMA-20-343-01, isolate the BrightSpeed CT, both LOGIQ e ultrasounds, and the Optima XR200amx onto a DICOM-only VLAN with strict ACLs. Coordinate with Biomed for post-change DICOM validation.", + status: TicketStatus.TO_DO, + category: TicketCategory.CONFIG_CHANGE, + department: "Radiology", + source: TicketSource.WORKFLOW, + sourceWorkflowName: "Emergency CT: Acute Stroke / Trauma Protocol", + linkedCveIds: ["CVE-2020-25175"], + linkedAssetIds: [ + "rad-ct-001", + "rad-us-001", + "rad-us-002", + "rad-xr-001", + ], + }, + ], + }, + { + summary: "Cisco ASA EXTRABACON Upgrades (CVE-2016-6366)", + description: + "Upgrade both ASA 5505 appliances (perimeter firewall + remote radiology VPN gateway) from 8.2 to a patched 9.1.x release.", + status: TicketStatus.REQUIRES_APPROVAL, + category: TicketCategory.PATCH, + department: "IT", + source: TicketSource.MANUAL, + linkedCveIds: ["CVE-2016-6366"], + linkedAssetIds: ["rad-fw-001", "rad-vpn-001"], + children: [ + { + summary: "Upgrade Perimeter Firewall ASA 8.2 → 9.1(7.21)", + description: + "SNMP RCE on ASA 8.x. Upgrade rad-fw-001 to a patched 9.1.x release in a maintenance window.", + status: TicketStatus.REQUIRES_APPROVAL, + category: TicketCategory.PATCH, + department: "IT", + source: TicketSource.MANUAL, + scheduledAt: inDays(7), + linkedCveIds: ["CVE-2016-6366"], + linkedAssetIds: ["rad-fw-001"], + comments: ["~30 min downtime expected; backup config staged."], + }, + { + summary: "Upgrade Cisco ASA VPN gateway (rad-vpn-001)", + description: + "Coordinate with remote radiology coverage. Upgrade ASA 8.2 → 9.1.x to remediate EXTRABACON.", + status: TicketStatus.IN_PROGRESS, + category: TicketCategory.PATCH, + department: "IT", + source: TicketSource.WORKFLOW, + sourceWorkflowName: "Remote Radiology — After-Hours Imaging Coverage", + scheduledAt: inDays(3), + linkedCveIds: ["CVE-2016-6366"], + linkedAssetIds: ["rad-vpn-001"], + }, + ], + }, + { + summary: "Patient Device Roadmap", + description: + "Tracks longer-horizon decisions for patient monitor and infusion pump fleets — vendor engagement, fleet refresh planning.", + status: TicketStatus.TO_DO, + category: TicketCategory.OTHER, + department: "Biomed", + source: TicketSource.MANUAL, + children: [ + { + summary: "Investigate IntelliVue MP5 firmware update availability", + description: + "Open ticket with Philips on patient monitor firmware roadmap and known vulns.", + status: TicketStatus.TO_DO, + category: TicketCategory.OTHER, + department: "Biomed", + source: TicketSource.MANUAL, + linkedAssetIds: Array.from( + { length: 13 }, + (_, i) => `rad-mon-${String(i + 1).padStart(3, "0")}`, + ), + }, + { + summary: "Replace Baxter Sigma Spectrum infusion pumps", + description: + "Proposed full fleet replacement deferred — out-of-budget this FY. Revisit in next capital cycle.", + status: TicketStatus.DONE, + category: TicketCategory.OTHER, + department: "Biomed", + lifeSafety: true, + source: TicketSource.MANUAL, + linkedAssetIds: Array.from( + { length: 23 }, + (_, i) => `rad-pump-${String(i + 1).padStart(3, "0")}`, + ), + comments: ["Deferred per Admin — revisit FY27 capital plan."], + }, + ], + }, +]; + async function clearDatabase() { console.log("🗑️ Clearing database..."); + // Tickets first — they reference assets/vulns/remediations/advisories/workflows/users. + // Comments cascade with their parent ticket; the implicit m2m join rows cascade too. + await prisma.ticketComment.deleteMany(); + await prisma.workOrderTicket.deleteMany(); // Workflows cascade to Node and Connection await prisma.workflow.deleteMany(); // Delete in order of dependencies (child tables first) @@ -703,6 +1101,8 @@ async function clearDatabase() { await prisma.deviceGroup.deleteMany(); await prisma.integration.deleteMany(); await prisma.memory.deleteMany(); + await prisma.categoryColor.deleteMany(); + await prisma.department.deleteMany(); console.log("✅ Database cleared"); } @@ -1309,6 +1709,146 @@ async function seedWorkflows(userId: string) { console.log("✅ Workflow seeding complete"); } +async function seedCategoryColors() { + console.log("\n🌱 Seeding category colors..."); + await Promise.all( + SAMPLE_CATEGORY_COLORS.map((c) => + prisma.categoryColor.upsert({ + where: { category: c.category }, + update: { color: c.color }, + create: c, + }), + ), + ); + console.log(`✅ Seeded ${SAMPLE_CATEGORY_COLORS.length} category colors`); +} + +async function seedDepartments(userId: string) { + console.log("\n🌱 Seeding departments..."); + + const departments = await Promise.all( + SAMPLE_DEPARTMENTS.map((dept) => + prisma.department.upsert({ + where: { name: dept.name }, + update: dept, + create: dept, + }), + ), + ); + + const seedUserDept = departments.find( + (d) => d.name === SEED_USER_DEPARTMENT, + ); + if (seedUserDept) { + await prisma.user.update({ + where: { id: userId }, + data: { departmentId: seedUserDept.id }, + }); + } + + console.log(`✅ Seeded ${departments.length} departments`); + return departments; +} + +async function createWorkOrderTicket( + ticket: SampleTicket, + userId: string, + parentId: string | null, +) { + const department = await prisma.department.findUnique({ + where: { name: ticket.department }, + }); + + const sourceWorkflow = ticket.sourceWorkflowName + ? await prisma.workflow.findFirst({ + where: { name: ticket.sourceWorkflowName }, + }) + : null; + + const linkedVulns = ticket.linkedCveIds?.length + ? await prisma.vulnerability.findMany({ + where: { cveId: { in: ticket.linkedCveIds } }, + select: { id: true }, + }) + : []; + + const linkedAssets = ticket.linkedAssetIds?.length + ? await prisma.asset.findMany({ + where: { id: { in: ticket.linkedAssetIds } }, + select: { id: true }, + }) + : []; + + const linkedRemediations = ticket.linkedCveIds?.length + ? await prisma.remediation.findMany({ + where: { vulnerability: { cveId: { in: ticket.linkedCveIds } } }, + select: { id: true }, + }) + : []; + + const linkedIssues = + ticket.linkedAssetIds?.length && ticket.linkedCveIds?.length + ? await prisma.issue.findMany({ + where: { + assetId: { in: ticket.linkedAssetIds }, + vulnerability: { cveId: { in: ticket.linkedCveIds } }, + }, + select: { id: true }, + }) + : []; + + return prisma.workOrderTicket.create({ + data: { + summary: ticket.summary, + description: ticket.description, + status: ticket.status, + category: ticket.category, + lifeSafety: ticket.lifeSafety ?? false, + source: ticket.source, + sourceWorkflowId: sourceWorkflow?.id, + departmentId: department?.id, + parentId, + creatorId: userId, + assigneeId: userId, + scheduledAt: ticket.scheduledAt, + vulnerabilities: { connect: linkedVulns.map((v) => ({ id: v.id })) }, + assets: { connect: linkedAssets.map((a) => ({ id: a.id })) }, + remediations: { + connect: linkedRemediations.map((r) => ({ id: r.id })), + }, + issues: { connect: linkedIssues.map((i) => ({ id: i.id })) }, + comments: ticket.comments?.length + ? { + create: ticket.comments.map((body) => ({ + body, + authorId: userId, + })), + } + : undefined, + }, + }); +} + +async function seedWorkOrderTickets(userId: string) { + console.log("\n🌱 Seeding work order tickets..."); + + let parentCount = 0; + let childCount = 0; + + for (const parent of SAMPLE_CHANGE_TICKETS) { + const created = await createWorkOrderTicket(parent, userId, null); + parentCount++; + for (const child of parent.children ?? []) { + await createWorkOrderTicket(child, userId, created.id); + childCount++; + } + } + + console.log( + `✅ Seeded ${parentCount} parent tickets and ${childCount} child tickets`, + ); +} + async function main() { console.log("🌱 Starting database seed...\n"); @@ -1320,6 +1860,8 @@ async function main() { const user = await createOrGetSeedUser(); + await seedDepartments(user.id); + await seedCategoryColors(); await seedDeviceGroups(); await seedAssets(user.id); await seedVulnerabilities(user.id); @@ -1328,6 +1870,7 @@ async function main() { await seedIssues(); await seedWorkflows(user.id); await seedMemories(user.id); + await seedWorkOrderTickets(user.id); console.log("\n✅ Database seeding completed successfully!"); console.log(`\n📧 Login with: ${SEED_USER.email} / ${SEED_USER.password}`); diff --git a/src/app/(dashboard)/(rest)/settings/departments/page.tsx b/src/app/(dashboard)/(rest)/settings/departments/page.tsx new file mode 100644 index 00000000..9be8d99d --- /dev/null +++ b/src/app/(dashboard)/(rest)/settings/departments/page.tsx @@ -0,0 +1,30 @@ +import { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { + DepartmentsContainer, + DepartmentsError, + DepartmentsList, + DepartmentsLoading, +} from "@/features/departments/components/departments"; +import { prefetchDepartments } from "@/features/departments/server/prefetch"; +import { requireAuth } from "@/lib/auth-utils"; +import { HydrateClient } from "@/trpc/server"; + +const Page = async () => { + await requireAuth(); + prefetchDepartments(); + + return ( + + + }> + }> + + + + + + ); +}; + +export default Page; diff --git a/src/app/(dashboard)/(rest)/settings/tag-colors/page.tsx b/src/app/(dashboard)/(rest)/settings/tag-colors/page.tsx new file mode 100644 index 00000000..c52564da --- /dev/null +++ b/src/app/(dashboard)/(rest)/settings/tag-colors/page.tsx @@ -0,0 +1,32 @@ +import { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { prefetchDepartments } from "@/features/departments/server/prefetch"; +import { + TagColorsContainer, + TagColorsError, + TagColorsList, + TagColorsLoading, +} from "@/features/tag-colors/components/tag-colors"; +import { prefetchCategoryColors } from "@/features/tag-colors/server/prefetch"; +import { requireAuth } from "@/lib/auth-utils"; +import { HydrateClient } from "@/trpc/server"; + +const Page = async () => { + await requireAuth(); + prefetchDepartments(); + prefetchCategoryColors(); + + return ( + + + }> + }> + + + + + + ); +}; + +export default Page; diff --git a/src/app/(dashboard)/(rest)/tracking/[ticketId]/page.tsx b/src/app/(dashboard)/(rest)/tracking/[ticketId]/page.tsx new file mode 100644 index 00000000..ab0915fa --- /dev/null +++ b/src/app/(dashboard)/(rest)/tracking/[ticketId]/page.tsx @@ -0,0 +1,32 @@ +import { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { + TicketDetailError, + TicketDetailLoading, + TicketDetailPage, +} from "@/features/tracking/components/ticket-detail"; +import { prefetchTrackingTicket } from "@/features/tracking/server/prefetch"; +import { requireAuth } from "@/lib/auth-utils"; +import { HydrateClient } from "@/trpc/server"; + +interface PageProps { + params: Promise<{ ticketId: string }>; +} + +const Page = async ({ params }: PageProps) => { + await requireAuth(); + const { ticketId } = await params; + await prefetchTrackingTicket(ticketId); + + return ( + + }> + }> + + + + + ); +}; + +export default Page; diff --git a/src/app/(dashboard)/(rest)/tracking/page.tsx b/src/app/(dashboard)/(rest)/tracking/page.tsx new file mode 100644 index 00000000..c97bf52e --- /dev/null +++ b/src/app/(dashboard)/(rest)/tracking/page.tsx @@ -0,0 +1,18 @@ +import { + TrackingContainer, + TrackingError, + TrackingList, + TrackingLoading, +} from "@/features/tracking/components/tracking"; +import { trackingParamsLoader } from "@/features/tracking/server/params-loader"; +import { prefetchTrackingTickets } from "@/features/tracking/server/prefetch"; +import { createListPage } from "@/lib/page-factory"; + +export default createListPage({ + paramsLoader: trackingParamsLoader, + prefetch: prefetchTrackingTickets, + Container: TrackingContainer, + List: TrackingList, + Loading: TrackingLoading, + Error: TrackingError, +}); diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 37df4999..8f78dffe 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -3,6 +3,7 @@ import { BugIcon, ChevronDownIcon, + ListChecksIcon, ComputerIcon, CpuIcon, ExternalLink, @@ -74,6 +75,11 @@ const mainItems = [ icon: ShieldAlertIcon, url: "/advisories", }, + { + title: "Tracking", + icon: ListChecksIcon, + url: "/tracking", + }, { title: "Settings", icon: SettingsIcon, diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index 6544f2c8..2194258f 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -3,8 +3,10 @@ import { type Column, type ColumnDef, + type ExpandedState, flexRender, getCoreRowModel, + getExpandedRowModel, type Row, type RowData, type SortingState, @@ -205,6 +207,12 @@ interface DataTableProps< nestedColumns?: ColumnDef[]; /** Key on each row's data that holds the nested array. */ nestedDataKey?: keyof TData; + /** + * When true, child rows render inline in the main table (sharing column + * widths) instead of as a separate nested mini-table. Requires nestedDataKey + * and that child rows are structurally compatible with the parent columns. + */ + inlineNestedRows?: boolean; } export function DataTable< @@ -220,6 +228,7 @@ export function DataTable< rowOnclick, nestedColumns, nestedDataKey, + inlineNestedRows, }: DataTableProps) { const [sorting, setSorting] = React.useState([]); const [_params, setParams] = usePaginationParams(); @@ -227,11 +236,14 @@ export function DataTable< const [expandedRows, setExpandedRows] = React.useState>( new Set(), ); + const [expandedTanstack, setExpandedTanstack] = + React.useState({}); // Reset expanded rows when the page data changes // biome-ignore lint/correctness/useExhaustiveDependencies: do use paginated data as dependency here to trigger on page change React.useEffect(() => { setExpandedRows(new Set()); + setExpandedTanstack({}); }, [paginatedData.page]); const [columnVisibility, setColumnVisibility] = @@ -256,6 +268,9 @@ export function DataTable< } }, [sorting, setParams]); + const hasNestedTable = nestedColumns && nestedDataKey; + const isInlineNested = !!(inlineNestedRows && nestedDataKey); + const table = useReactTable({ data: paginatedData.items, columns, @@ -263,13 +278,24 @@ export function DataTable< manualSorting: true, onSortingChange: setSorting, onColumnVisibilityChange: setColumnVisibility, - state: { sorting, columnVisibility, pagination }, + state: { + sorting, + columnVisibility, + pagination, + ...(isInlineNested ? { expanded: expandedTanstack } : {}), + }, manualPagination: true, rowCount: paginatedData.totalCount, + ...(isInlineNested && nestedDataKey + ? { + getSubRows: (row: TData) => + (row[nestedDataKey] as unknown as TData[]) ?? undefined, + getExpandedRowModel: getExpandedRowModel(), + onExpandedChange: setExpandedTanstack, + } + : {}), }); - const hasNestedTable = nestedColumns && nestedDataKey; - const toggleRow = (rowId: string) => { setExpandedRows((prev) => { const next = new Set(prev); @@ -311,7 +337,9 @@ export function DataTable< {table.getHeaderGroups().map((headerGroup) => ( - {hasNestedTable && } + {(hasNestedTable || isInlineNested) && ( + + )} {headerGroup.headers.map((header) => ( {!isLoading && table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => { - const nestedData = hasNestedTable - ? (row.original[nestedDataKey] as TNestedData[]) - : []; + const nestedData = + hasNestedTable && !isInlineNested + ? (row.original[nestedDataKey] as TNestedData[]) + : []; const hasNestedData = Array.isArray(nestedData) && nestedData.length > 0; - const isExpanded = expandedRows.has(row.id); + const legacyExpanded = + hasNestedTable && !isInlineNested && expandedRows.has(row.id); + + const inlineCanExpand = isInlineNested && row.getCanExpand(); + const inlineIsExpanded = isInlineNested && row.getIsExpanded(); + const showChevron = hasNestedTable || isInlineNested; + const chevronVisible = isInlineNested + ? inlineCanExpand + : true; + const chevronExpanded = isInlineNested + ? inlineIsExpanded + : !!legacyExpanded; + const isChildRow = isInlineNested && row.depth > 0; return ( rowOnclick(row) : undefined} > - {hasNestedTable && ( - - + {showChevron && ( + + {chevronVisible && ( + + )} )} {(() => { const cells = row.getVisibleCells(); const rendered: React.ReactNode[] = []; let skip = 0; - for (const cell of cells) { + for (let i = 0; i < cells.length; i++) { if (skip > 0) { skip--; continue; } + const cell = cells[i]; const colSpan = cell.column.columnDef.meta?.colSpan?.(row) ?? 1; if (colSpan > 1) { @@ -403,6 +459,11 @@ export function DataTable< "py-4 first-of-type:pl-4 last-of-type:pr-4", cell.column.columnDef.meta?.cellClassName, )} + style={ + isChildRow && i === 0 + ? { paddingLeft: `${row.depth * 1.5 + 1}rem` } + : undefined + } > {flexRender( cell.column.columnDef.cell, @@ -415,7 +476,7 @@ export function DataTable< })()} - {hasNestedTable && isExpanded && ( + {hasNestedTable && !isInlineNested && legacyExpanded && (
@@ -433,7 +494,9 @@ export function DataTable< ) : ( {isLoading ? ( diff --git a/src/features/departments/components/departments.tsx b/src/features/departments/components/departments.tsx new file mode 100644 index 00000000..61b45199 --- /dev/null +++ b/src/features/departments/components/departments.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { PencilIcon, PlusIcon, TrashIcon } from "lucide-react"; +import { useState } from "react"; +import { + EntityContainer, + EntityHeader, + ErrorView, + LoadingView, +} from "@/components/entity-components"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Textarea } from "@/components/ui/textarea"; +import { ColorPicker } from "@/features/tag-colors/components/color-picker"; +import { DEFAULT_HUE, getChipClass, type TagHue } from "@/features/tag-colors/palette"; +import { + useCreateDepartment, + useRemoveDepartment, + useSuspenseDepartments, + useUpdateDepartment, +} from "../hooks/use-departments"; + +type DepartmentRow = ReturnType["data"][number]; + +interface DepartmentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initial?: DepartmentRow; +} + +const DepartmentDialog = ({ + open, + onOpenChange, + initial, +}: DepartmentDialogProps) => { + const isEditing = !!initial; + const [name, setName] = useState(initial?.name ?? ""); + const [description, setDescription] = useState(initial?.description ?? ""); + const [color, setColor] = useState( + (initial?.color as TagHue | null) ?? DEFAULT_HUE, + ); + + const createMutation = useCreateDepartment(); + const updateMutation = useUpdateDepartment(); + const pending = createMutation.isPending || updateMutation.isPending; + + const handleSubmit = async () => { + if (!name.trim()) return; + if (isEditing) { + await updateMutation.mutateAsync({ + id: initial.id, + name, + description: description || null, + color, + }); + } else { + await createMutation.mutateAsync({ + name, + description: description || null, + color, + }); + } + onOpenChange(false); + }; + + return ( + + + + + {isEditing ? "Edit department" : "New department"} + + +
+
+ + setName(e.target.value)} + placeholder="e.g. Radiology" + autoFocus + /> +
+
+ +