@omersx/bemms 0.0.0-stage → 1.0.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,684 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { addTicketComment } from "@/lib/actions/tickets";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
9
|
+
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
10
|
+
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
11
|
+
import {
|
|
12
|
+
Send,
|
|
13
|
+
Lock,
|
|
14
|
+
Globe,
|
|
15
|
+
Loader2,
|
|
16
|
+
Wrench,
|
|
17
|
+
MessageCircle,
|
|
18
|
+
Stethoscope,
|
|
19
|
+
Paperclip,
|
|
20
|
+
Image as ImageIcon,
|
|
21
|
+
FileText,
|
|
22
|
+
Download,
|
|
23
|
+
X,
|
|
24
|
+
File as FileIcon,
|
|
25
|
+
} from "lucide-react";
|
|
26
|
+
import { cn } from "@/lib/utils";
|
|
27
|
+
|
|
28
|
+
interface Comment {
|
|
29
|
+
id: string;
|
|
30
|
+
body: string;
|
|
31
|
+
visibility: string;
|
|
32
|
+
createdAt: Date | string;
|
|
33
|
+
authorUserId?: string;
|
|
34
|
+
author?: {
|
|
35
|
+
id?: string;
|
|
36
|
+
fullName?: string | null;
|
|
37
|
+
} | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface PendingAttachment {
|
|
41
|
+
id: string;
|
|
42
|
+
file: File;
|
|
43
|
+
previewUrl?: string;
|
|
44
|
+
isImage: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ParsedAttachment {
|
|
48
|
+
name: string;
|
|
49
|
+
url: string;
|
|
50
|
+
size: number;
|
|
51
|
+
type?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const QUICK_CHAT_TEMPLATES = [
|
|
55
|
+
"When is this expected to be ready for patient care?",
|
|
56
|
+
"The device is currently in Room / Bed for inspection.",
|
|
57
|
+
"Please call our nurse station extension when arriving.",
|
|
58
|
+
"Thank you, device is verified and working properly.",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
function formatFileSize(bytes?: number): string {
|
|
62
|
+
if (!bytes || bytes === 0) return "0 B";
|
|
63
|
+
const k = 1024;
|
|
64
|
+
const sizes = ["B", "KB", "MB", "GB"];
|
|
65
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
66
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseCommentBody(rawBody: string): { text: string; attachments: ParsedAttachment[] } {
|
|
70
|
+
const attachments: ParsedAttachment[] = [];
|
|
71
|
+
const text = rawBody
|
|
72
|
+
.replace(/\[attachment:(\{.*?\})\]/g, (_, jsonStr) => {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(jsonStr);
|
|
75
|
+
if (parsed.url && parsed.name) {
|
|
76
|
+
attachments.push(parsed);
|
|
77
|
+
}
|
|
78
|
+
return "";
|
|
79
|
+
} catch {
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
.trim();
|
|
84
|
+
|
|
85
|
+
return { text, attachments };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function TicketCommentSection({
|
|
89
|
+
ticketId,
|
|
90
|
+
comments,
|
|
91
|
+
canAddInternal = false,
|
|
92
|
+
currentUserId,
|
|
93
|
+
assignedEngineerUserId,
|
|
94
|
+
assignedEngineerName,
|
|
95
|
+
}: {
|
|
96
|
+
ticketId: string;
|
|
97
|
+
comments: Comment[];
|
|
98
|
+
canAddInternal?: boolean;
|
|
99
|
+
currentUserId?: string;
|
|
100
|
+
assignedEngineerUserId?: string | null;
|
|
101
|
+
assignedEngineerName?: string | null;
|
|
102
|
+
}) {
|
|
103
|
+
const router = useRouter();
|
|
104
|
+
const [body, setBody] = useState("");
|
|
105
|
+
const [visibility, setVisibility] = useState<"public" | "internal">("public");
|
|
106
|
+
const [loading, setLoading] = useState(false);
|
|
107
|
+
const [pendingFiles, setPendingFiles] = useState<PendingAttachment[]>([]);
|
|
108
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
109
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
110
|
+
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
111
|
+
|
|
112
|
+
const [optimisticComments, setOptimisticComments] = useState<Comment[]>([]);
|
|
113
|
+
|
|
114
|
+
// Clear optimistic comments when server re-renders with new props
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
setOptimisticComments([]);
|
|
117
|
+
}, [comments]);
|
|
118
|
+
|
|
119
|
+
const allComments = [...comments, ...optimisticComments];
|
|
120
|
+
|
|
121
|
+
// If user cannot add internal notes (e.g. ward staff), filter out internal comments
|
|
122
|
+
const visibleComments = canAddInternal
|
|
123
|
+
? allComments
|
|
124
|
+
: allComments.filter((c) => c.visibility === "public");
|
|
125
|
+
|
|
126
|
+
// Sort chronologically (oldest first) like a chat thread
|
|
127
|
+
const sortedComments = [...visibleComments].sort(
|
|
128
|
+
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Auto-scroll to bottom on load and whenever comments change
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (scrollRef.current) {
|
|
134
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
135
|
+
}
|
|
136
|
+
}, [comments, optimisticComments]);
|
|
137
|
+
|
|
138
|
+
// Handle file selection from input
|
|
139
|
+
const addFiles = (files: FileList | File[]) => {
|
|
140
|
+
const newItems: PendingAttachment[] = [];
|
|
141
|
+
Array.from(files).forEach((file) => {
|
|
142
|
+
// 20MB limit
|
|
143
|
+
if (file.size > 20 * 1024 * 1024) {
|
|
144
|
+
toast.error(`File "${file.name}" exceeds 20MB limit`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const isImg = file.type.startsWith("image/");
|
|
148
|
+
newItems.push({
|
|
149
|
+
id: `${Date.now()}-${Math.random().toString(36).substring(2, 7)}`,
|
|
150
|
+
file,
|
|
151
|
+
isImage: isImg,
|
|
152
|
+
previewUrl: isImg ? URL.createObjectURL(file) : undefined,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (newItems.length > 0) {
|
|
157
|
+
setPendingFiles((prev) => [...prev, ...newItems]);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
162
|
+
if (e.target.files && e.target.files.length > 0) {
|
|
163
|
+
addFiles(e.target.files);
|
|
164
|
+
e.target.value = "";
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const removePendingFile = (id: string) => {
|
|
169
|
+
setPendingFiles((prev) => {
|
|
170
|
+
const target = prev.find((item) => item.id === id);
|
|
171
|
+
if (target?.previewUrl) {
|
|
172
|
+
URL.revokeObjectURL(target.previewUrl);
|
|
173
|
+
}
|
|
174
|
+
return prev.filter((item) => item.id !== id);
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Support pasting images from clipboard
|
|
179
|
+
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
180
|
+
if (e.clipboardData.files && e.clipboardData.files.length > 0) {
|
|
181
|
+
const files = Array.from(e.clipboardData.files);
|
|
182
|
+
const images = files.filter((f) => f.type.startsWith("image/"));
|
|
183
|
+
if (images.length > 0) {
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
addFiles(images);
|
|
186
|
+
toast.info(`Added ${images.length} image(s) from clipboard`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const handleSubmit = async () => {
|
|
192
|
+
const text = body.trim();
|
|
193
|
+
if ((!text && pendingFiles.length === 0) || loading) return;
|
|
194
|
+
setLoading(true);
|
|
195
|
+
|
|
196
|
+
// Save copy of pending files to process
|
|
197
|
+
const filesToUpload = [...pendingFiles];
|
|
198
|
+
const currentVis = canAddInternal ? visibility : "public";
|
|
199
|
+
|
|
200
|
+
// Create optimistic display
|
|
201
|
+
let optimisticBody = text;
|
|
202
|
+
filesToUpload.forEach((p) => {
|
|
203
|
+
optimisticBody += `\n[attachment:{"name":"${p.file.name}","url":"${
|
|
204
|
+
p.previewUrl || "#"
|
|
205
|
+
}","size":${p.file.size},"type":"${p.file.type}"}]`;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const tempComment: Comment = {
|
|
209
|
+
id: `temp-${Date.now()}`,
|
|
210
|
+
body: optimisticBody.trim(),
|
|
211
|
+
visibility: currentVis,
|
|
212
|
+
createdAt: new Date(),
|
|
213
|
+
authorUserId: currentUserId,
|
|
214
|
+
author: {
|
|
215
|
+
id: currentUserId,
|
|
216
|
+
fullName: "You",
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
setOptimisticComments((prev) => [...prev, tempComment]);
|
|
221
|
+
setBody("");
|
|
222
|
+
setPendingFiles([]);
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
// 1. Upload files first if any
|
|
226
|
+
const uploadedTags: string[] = [];
|
|
227
|
+
for (const item of filesToUpload) {
|
|
228
|
+
const formData = new FormData();
|
|
229
|
+
formData.append("file", item.file);
|
|
230
|
+
formData.append("visibility", currentVis);
|
|
231
|
+
|
|
232
|
+
const uploadRes = await fetch(`/api/tickets/${ticketId}/attachments`, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
body: formData,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (!uploadRes.ok) {
|
|
238
|
+
const errData = await uploadRes.json();
|
|
239
|
+
throw new Error(errData.error || `Failed to upload ${item.file.name}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const data = await uploadRes.json();
|
|
243
|
+
if (data.success && data.attachment) {
|
|
244
|
+
const tag = `[attachment:${JSON.stringify({
|
|
245
|
+
name: data.attachment.fileName,
|
|
246
|
+
url: data.attachment.url,
|
|
247
|
+
size: data.attachment.fileSize,
|
|
248
|
+
type: data.attachment.mimeType,
|
|
249
|
+
})}]`;
|
|
250
|
+
uploadedTags.push(tag);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 2. Assemble comment body with attachment tags
|
|
255
|
+
let finalBody = text;
|
|
256
|
+
if (uploadedTags.length > 0) {
|
|
257
|
+
finalBody = finalBody ? `${finalBody}\n\n${uploadedTags.join("\n")}` : uploadedTags.join("\n");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 3. Post comment
|
|
261
|
+
const res = await addTicketComment(ticketId, {
|
|
262
|
+
body: finalBody.trim(),
|
|
263
|
+
visibility: currentVis,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (res?.success) {
|
|
267
|
+
router.refresh();
|
|
268
|
+
toast.success(
|
|
269
|
+
currentVis === "internal"
|
|
270
|
+
? "Internal note posted"
|
|
271
|
+
: filesToUpload.length > 0
|
|
272
|
+
? "Message with attachments sent"
|
|
273
|
+
: "Message sent"
|
|
274
|
+
);
|
|
275
|
+
} else {
|
|
276
|
+
// Rollback on failure
|
|
277
|
+
setOptimisticComments((prev) => prev.filter((c) => c.id !== tempComment.id));
|
|
278
|
+
setBody(text);
|
|
279
|
+
setPendingFiles(filesToUpload);
|
|
280
|
+
toast.error((res as any)?.error || "Failed to post message");
|
|
281
|
+
}
|
|
282
|
+
} catch (err: any) {
|
|
283
|
+
setOptimisticComments((prev) => prev.filter((c) => c.id !== tempComment.id));
|
|
284
|
+
setBody(text);
|
|
285
|
+
setPendingFiles(filesToUpload);
|
|
286
|
+
toast.error(err.message || "An unexpected error occurred");
|
|
287
|
+
} finally {
|
|
288
|
+
setLoading(false);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
293
|
+
// Send on Enter (without Shift)
|
|
294
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
295
|
+
e.preventDefault();
|
|
296
|
+
handleSubmit();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<div className="flex flex-col h-[580px] rounded-xl overflow-hidden bg-background border shadow-xs">
|
|
302
|
+
{/* Hidden File Pickers */}
|
|
303
|
+
<input
|
|
304
|
+
ref={fileInputRef}
|
|
305
|
+
type="file"
|
|
306
|
+
multiple
|
|
307
|
+
className="hidden"
|
|
308
|
+
onChange={handleFileChange}
|
|
309
|
+
/>
|
|
310
|
+
<input
|
|
311
|
+
ref={imageInputRef}
|
|
312
|
+
type="file"
|
|
313
|
+
multiple
|
|
314
|
+
accept="image/*"
|
|
315
|
+
className="hidden"
|
|
316
|
+
onChange={handleFileChange}
|
|
317
|
+
/>
|
|
318
|
+
|
|
319
|
+
{/* Chat Sub-header */}
|
|
320
|
+
<div className="px-4 py-2.5 bg-muted/40 border-b flex items-center justify-between text-xs text-muted-foreground">
|
|
321
|
+
<div className="flex items-center gap-2">
|
|
322
|
+
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
|
323
|
+
<span>
|
|
324
|
+
{assignedEngineerName
|
|
325
|
+
? `Direct chat with ${assignedEngineerName}`
|
|
326
|
+
: "Direct chat with Biomedical Engineering"}
|
|
327
|
+
</span>
|
|
328
|
+
</div>
|
|
329
|
+
<span>{sortedComments.length} messages</span>
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
{/* Chat History / Bubble Thread Feed */}
|
|
333
|
+
<div
|
|
334
|
+
ref={scrollRef}
|
|
335
|
+
className="flex-1 overflow-y-auto p-4 space-y-3.5 bg-slate-50/50 dark:bg-muted/10"
|
|
336
|
+
>
|
|
337
|
+
{sortedComments.length === 0 ? (
|
|
338
|
+
<div className="h-full flex flex-col items-center justify-center text-muted-foreground space-y-2 p-6 text-center">
|
|
339
|
+
<MessageCircle className="w-10 h-10 opacity-20" />
|
|
340
|
+
<p className="text-sm font-medium">No messages yet in this ticket.</p>
|
|
341
|
+
<p className="text-xs max-w-sm">
|
|
342
|
+
Use this chat to ask the assigned engineer questions, confirm room locations, attach error screenshots, or receive live repair updates.
|
|
343
|
+
</p>
|
|
344
|
+
</div>
|
|
345
|
+
) : (
|
|
346
|
+
sortedComments.map((c) => (
|
|
347
|
+
<ChatBubble
|
|
348
|
+
key={c.id}
|
|
349
|
+
comment={c}
|
|
350
|
+
isCurrentUser={Boolean(
|
|
351
|
+
currentUserId &&
|
|
352
|
+
(c.authorUserId === currentUserId ||
|
|
353
|
+
(c.author as any)?.id === currentUserId)
|
|
354
|
+
)}
|
|
355
|
+
isAssignedEngineer={Boolean(
|
|
356
|
+
assignedEngineerUserId &&
|
|
357
|
+
(c.authorUserId === assignedEngineerUserId ||
|
|
358
|
+
(c.author as any)?.id === assignedEngineerUserId)
|
|
359
|
+
)}
|
|
360
|
+
/>
|
|
361
|
+
))
|
|
362
|
+
)}
|
|
363
|
+
</div>
|
|
364
|
+
|
|
365
|
+
{/* Quick clinical templates */}
|
|
366
|
+
<div className="px-4 py-1.5 bg-card border-t border-border/60 overflow-x-auto flex gap-1.5 scrollbar-none">
|
|
367
|
+
{QUICK_CHAT_TEMPLATES.map((tmpl) => (
|
|
368
|
+
<button
|
|
369
|
+
key={tmpl}
|
|
370
|
+
type="button"
|
|
371
|
+
onClick={() => setBody(tmpl)}
|
|
372
|
+
className="text-[11px] px-2.5 py-1 rounded-full border bg-muted/30 hover:bg-muted hover:text-foreground text-muted-foreground transition whitespace-nowrap shrink-0"
|
|
373
|
+
>
|
|
374
|
+
{tmpl}
|
|
375
|
+
</button>
|
|
376
|
+
))}
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
{/* Chat Input Box */}
|
|
380
|
+
<div className="p-3 bg-card border-t shadow-xs z-10 space-y-2">
|
|
381
|
+
{canAddInternal && (
|
|
382
|
+
<Tabs
|
|
383
|
+
value={visibility}
|
|
384
|
+
onValueChange={(v) => setVisibility(v as "public" | "internal")}
|
|
385
|
+
className="mb-1"
|
|
386
|
+
>
|
|
387
|
+
<TabsList className="grid w-full grid-cols-2 max-w-[340px] h-7">
|
|
388
|
+
<TabsTrigger value="public" className="text-xs data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
|
389
|
+
<Globe className="w-3.5 h-3.5 mr-1.5" /> Public Message
|
|
390
|
+
</TabsTrigger>
|
|
391
|
+
<TabsTrigger value="internal" className="text-xs data-[state=active]:bg-amber-500 data-[state=active]:text-white">
|
|
392
|
+
<Lock className="w-3.5 h-3.5 mr-1.5" /> Internal Note
|
|
393
|
+
</TabsTrigger>
|
|
394
|
+
</TabsList>
|
|
395
|
+
</Tabs>
|
|
396
|
+
)}
|
|
397
|
+
|
|
398
|
+
{/* Pending Attachments Tray */}
|
|
399
|
+
{pendingFiles.length > 0 && (
|
|
400
|
+
<div className="flex flex-wrap gap-2 p-2 bg-muted/40 rounded-lg border border-dashed border-border animate-in fade-in">
|
|
401
|
+
{pendingFiles.map((p) => (
|
|
402
|
+
<div
|
|
403
|
+
key={p.id}
|
|
404
|
+
className="relative flex items-center gap-2 bg-background border rounded-lg p-1.5 pr-2.5 text-xs shadow-xs group"
|
|
405
|
+
>
|
|
406
|
+
{p.isImage && p.previewUrl ? (
|
|
407
|
+
<img
|
|
408
|
+
src={p.previewUrl}
|
|
409
|
+
alt={p.file.name}
|
|
410
|
+
className="w-8 h-8 rounded object-cover border"
|
|
411
|
+
/>
|
|
412
|
+
) : (
|
|
413
|
+
<div className="w-8 h-8 rounded bg-primary/10 flex items-center justify-center text-primary">
|
|
414
|
+
<FileText className="w-4 h-4" />
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
<div className="max-w-[140px] truncate">
|
|
418
|
+
<p className="font-medium truncate text-[11px]">{p.file.name}</p>
|
|
419
|
+
<p className="text-[10px] text-muted-foreground">{formatFileSize(p.file.size)}</p>
|
|
420
|
+
</div>
|
|
421
|
+
<button
|
|
422
|
+
type="button"
|
|
423
|
+
onClick={() => removePendingFile(p.id)}
|
|
424
|
+
className="ml-1 text-muted-foreground hover:text-destructive p-0.5 rounded-full hover:bg-muted"
|
|
425
|
+
title="Remove file"
|
|
426
|
+
>
|
|
427
|
+
<X className="w-3.5 h-3.5" />
|
|
428
|
+
</button>
|
|
429
|
+
</div>
|
|
430
|
+
))}
|
|
431
|
+
</div>
|
|
432
|
+
)}
|
|
433
|
+
|
|
434
|
+
<div
|
|
435
|
+
className={cn(
|
|
436
|
+
"rounded-xl border transition-all focus-within:ring-2",
|
|
437
|
+
visibility === "internal"
|
|
438
|
+
? "bg-amber-50/40 dark:bg-amber-950/20 border-amber-300 ring-amber-400"
|
|
439
|
+
: "bg-background border-input ring-primary/20"
|
|
440
|
+
)}
|
|
441
|
+
>
|
|
442
|
+
<Textarea
|
|
443
|
+
value={body}
|
|
444
|
+
onChange={(e) => setBody(e.target.value)}
|
|
445
|
+
onKeyDown={handleKeyDown}
|
|
446
|
+
onPaste={handlePaste}
|
|
447
|
+
placeholder={
|
|
448
|
+
visibility === "internal"
|
|
449
|
+
? "Type an internal note for biomedical team (Press Enter to send)..."
|
|
450
|
+
: "Type a message to biomedical engineering (or paste screenshot, press Enter to send)..."
|
|
451
|
+
}
|
|
452
|
+
className="border-0 bg-transparent focus-visible:ring-0 resize-none min-h-[60px] max-h-[120px] text-xs sm:text-sm p-3"
|
|
453
|
+
/>
|
|
454
|
+
|
|
455
|
+
{/* Action Toolbar */}
|
|
456
|
+
<div className="flex items-center justify-between p-2 pt-0 border-t border-border/40">
|
|
457
|
+
{/* Attachment Icons / File Picker buttons */}
|
|
458
|
+
<div className="flex items-center gap-1">
|
|
459
|
+
<Button
|
|
460
|
+
type="button"
|
|
461
|
+
variant="ghost"
|
|
462
|
+
size="sm"
|
|
463
|
+
onClick={() => fileInputRef.current?.click()}
|
|
464
|
+
disabled={loading}
|
|
465
|
+
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground gap-1 rounded-md"
|
|
466
|
+
title="Attach document or file (PDF, DOCX, TXT, Excel)"
|
|
467
|
+
>
|
|
468
|
+
<Paperclip className="w-3.5 h-3.5" />
|
|
469
|
+
<span className="hidden sm:inline text-[11px]">Attach</span>
|
|
470
|
+
</Button>
|
|
471
|
+
|
|
472
|
+
<Button
|
|
473
|
+
type="button"
|
|
474
|
+
variant="ghost"
|
|
475
|
+
size="sm"
|
|
476
|
+
onClick={() => imageInputRef.current?.click()}
|
|
477
|
+
disabled={loading}
|
|
478
|
+
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground gap-1 rounded-md"
|
|
479
|
+
title="Attach photo or screenshot"
|
|
480
|
+
>
|
|
481
|
+
<ImageIcon className="w-3.5 h-3.5" />
|
|
482
|
+
<span className="hidden sm:inline text-[11px]">Photo</span>
|
|
483
|
+
</Button>
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
{/* Right side: Helper + Send button */}
|
|
487
|
+
<div className="flex items-center gap-2">
|
|
488
|
+
<span className="text-[10px] text-muted-foreground hidden md:inline">
|
|
489
|
+
<kbd className="font-mono bg-muted px-1 rounded text-[9px]">Enter</kbd> send, <kbd className="font-mono bg-muted px-1 rounded text-[9px]">Shift+Enter</kbd> newline
|
|
490
|
+
</span>
|
|
491
|
+
|
|
492
|
+
<Button
|
|
493
|
+
size="sm"
|
|
494
|
+
onClick={handleSubmit}
|
|
495
|
+
disabled={(!body.trim() && pendingFiles.length === 0) || loading}
|
|
496
|
+
className={cn(
|
|
497
|
+
"h-7 text-xs gap-1.5 px-3 font-semibold",
|
|
498
|
+
visibility === "internal" ? "bg-amber-600 hover:bg-amber-700 text-white" : ""
|
|
499
|
+
)}
|
|
500
|
+
>
|
|
501
|
+
{loading ? (
|
|
502
|
+
<>
|
|
503
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
504
|
+
Sending...
|
|
505
|
+
</>
|
|
506
|
+
) : (
|
|
507
|
+
<>
|
|
508
|
+
<Send className="w-3.5 h-3.5" />
|
|
509
|
+
Send
|
|
510
|
+
</>
|
|
511
|
+
)}
|
|
512
|
+
</Button>
|
|
513
|
+
</div>
|
|
514
|
+
</div>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function ChatBubble({
|
|
522
|
+
comment,
|
|
523
|
+
isCurrentUser,
|
|
524
|
+
isAssignedEngineer,
|
|
525
|
+
}: {
|
|
526
|
+
comment: Comment;
|
|
527
|
+
isCurrentUser: boolean;
|
|
528
|
+
isAssignedEngineer: boolean;
|
|
529
|
+
}) {
|
|
530
|
+
const isInternal = comment.visibility === "internal";
|
|
531
|
+
|
|
532
|
+
const formattedTime = comment.createdAt
|
|
533
|
+
? new Date(comment.createdAt).toLocaleTimeString([], {
|
|
534
|
+
hour: "2-digit",
|
|
535
|
+
minute: "2-digit",
|
|
536
|
+
})
|
|
537
|
+
: "";
|
|
538
|
+
|
|
539
|
+
const { text, attachments } = parseCommentBody(comment.body);
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<div
|
|
543
|
+
className={cn(
|
|
544
|
+
"flex gap-2.5 max-w-[85%] sm:max-w-[75%]",
|
|
545
|
+
isCurrentUser ? "ml-auto flex-row-reverse" : "mr-auto"
|
|
546
|
+
)}
|
|
547
|
+
>
|
|
548
|
+
{/* Avatar */}
|
|
549
|
+
<Avatar className="h-7 w-7 shrink-0 mt-1">
|
|
550
|
+
<AvatarFallback
|
|
551
|
+
className={cn(
|
|
552
|
+
"text-[10px] font-bold",
|
|
553
|
+
isInternal
|
|
554
|
+
? "bg-amber-200 text-amber-900 dark:bg-amber-900 dark:text-amber-200"
|
|
555
|
+
: isAssignedEngineer
|
|
556
|
+
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
|
557
|
+
: isCurrentUser
|
|
558
|
+
? "bg-primary text-primary-foreground"
|
|
559
|
+
: "bg-muted text-muted-foreground"
|
|
560
|
+
)}
|
|
561
|
+
>
|
|
562
|
+
{comment.author?.fullName?.substring(0, 2).toUpperCase() || "U"}
|
|
563
|
+
</AvatarFallback>
|
|
564
|
+
</Avatar>
|
|
565
|
+
|
|
566
|
+
{/* Bubble Content */}
|
|
567
|
+
<div className="space-y-1 min-w-0">
|
|
568
|
+
{/* Author Label */}
|
|
569
|
+
<div
|
|
570
|
+
className={cn(
|
|
571
|
+
"flex items-center gap-1.5 text-[11px]",
|
|
572
|
+
isCurrentUser ? "justify-end" : "justify-start"
|
|
573
|
+
)}
|
|
574
|
+
>
|
|
575
|
+
<span className="font-semibold text-foreground truncate">
|
|
576
|
+
{isCurrentUser ? "You" : comment.author?.fullName || "Staff"}
|
|
577
|
+
</span>
|
|
578
|
+
|
|
579
|
+
{isAssignedEngineer && !isCurrentUser ? (
|
|
580
|
+
<span className="bg-blue-100 dark:bg-blue-950 text-blue-700 dark:text-blue-300 px-1.5 py-0.2 rounded font-medium text-[10px] flex items-center gap-1">
|
|
581
|
+
<Wrench className="w-2.5 h-2.5" /> Engineer
|
|
582
|
+
</span>
|
|
583
|
+
) : !isCurrentUser && (comment.author as any)?.jobTitle ? (
|
|
584
|
+
<span className="bg-muted text-muted-foreground px-1.5 py-0.2 rounded font-medium text-[10px] flex items-center gap-1">
|
|
585
|
+
<Stethoscope className="w-2.5 h-2.5 text-primary" />
|
|
586
|
+
{(comment.author as any).jobTitle}
|
|
587
|
+
</span>
|
|
588
|
+
) : null}
|
|
589
|
+
|
|
590
|
+
{isInternal && (
|
|
591
|
+
<span className="bg-amber-200 dark:bg-amber-900 text-amber-900 dark:text-amber-200 px-1 py-0.2 rounded font-medium text-[9px] flex items-center gap-0.5">
|
|
592
|
+
<Lock className="w-2.5 h-2.5" /> Internal
|
|
593
|
+
</span>
|
|
594
|
+
)}
|
|
595
|
+
|
|
596
|
+
<span className="text-[10px] text-muted-foreground ml-1">
|
|
597
|
+
{formattedTime}
|
|
598
|
+
</span>
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
{/* Bubble Text & Attachments */}
|
|
602
|
+
<div
|
|
603
|
+
className={cn(
|
|
604
|
+
"p-3 rounded-2xl text-xs sm:text-sm leading-relaxed shadow-xs space-y-2 break-words",
|
|
605
|
+
isCurrentUser
|
|
606
|
+
? "bg-primary text-primary-foreground rounded-tr-xs"
|
|
607
|
+
: isInternal
|
|
608
|
+
? "bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 text-amber-950 dark:text-amber-100 rounded-tl-xs"
|
|
609
|
+
: isAssignedEngineer
|
|
610
|
+
? "bg-card border-2 border-blue-200/80 dark:border-blue-900/60 text-foreground rounded-tl-xs"
|
|
611
|
+
: "bg-card border text-foreground rounded-tl-xs"
|
|
612
|
+
)}
|
|
613
|
+
>
|
|
614
|
+
{text && (
|
|
615
|
+
<div className="whitespace-pre-wrap leading-relaxed">
|
|
616
|
+
{text}
|
|
617
|
+
</div>
|
|
618
|
+
)}
|
|
619
|
+
|
|
620
|
+
{/* Render Attached Files */}
|
|
621
|
+
{attachments.length > 0 && (
|
|
622
|
+
<div className="space-y-2 pt-0.5">
|
|
623
|
+
{attachments.map((att, idx) => {
|
|
624
|
+
const isImage =
|
|
625
|
+
att.type?.startsWith("image/") ||
|
|
626
|
+
[".png", ".jpg", ".jpeg", ".webp", ".gif"].some((ext) =>
|
|
627
|
+
att.name.toLowerCase().endsWith(ext)
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
if (isImage) {
|
|
631
|
+
return (
|
|
632
|
+
<a
|
|
633
|
+
key={idx}
|
|
634
|
+
href={att.url}
|
|
635
|
+
target="_blank"
|
|
636
|
+
rel="noopener noreferrer"
|
|
637
|
+
className="block overflow-hidden rounded-lg border border-border/40 hover:opacity-95 transition group"
|
|
638
|
+
>
|
|
639
|
+
<img
|
|
640
|
+
src={att.url}
|
|
641
|
+
alt={att.name}
|
|
642
|
+
className="max-h-56 max-w-full rounded-lg object-contain bg-black/5 dark:bg-white/5"
|
|
643
|
+
loading="lazy"
|
|
644
|
+
/>
|
|
645
|
+
<div className="p-1 px-2 text-[10px] flex items-center justify-between opacity-80 group-hover:opacity-100 bg-black/30 text-white">
|
|
646
|
+
<span className="truncate max-w-[180px]">{att.name}</span>
|
|
647
|
+
<span>{formatFileSize(att.size)}</span>
|
|
648
|
+
</div>
|
|
649
|
+
</a>
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return (
|
|
654
|
+
<a
|
|
655
|
+
key={idx}
|
|
656
|
+
href={att.url}
|
|
657
|
+
download={att.name}
|
|
658
|
+
target="_blank"
|
|
659
|
+
rel="noopener noreferrer"
|
|
660
|
+
className={cn(
|
|
661
|
+
"flex items-center gap-2 p-2 px-2.5 rounded-lg border text-xs transition",
|
|
662
|
+
isCurrentUser
|
|
663
|
+
? "bg-white/10 hover:bg-white/20 border-white/20 text-primary-foreground"
|
|
664
|
+
: "bg-muted/60 hover:bg-muted border-border text-foreground"
|
|
665
|
+
)}
|
|
666
|
+
>
|
|
667
|
+
<div className="p-1 rounded bg-background/80 text-primary">
|
|
668
|
+
<FileText className="w-3.5 h-3.5" />
|
|
669
|
+
</div>
|
|
670
|
+
<div className="flex-1 min-w-0">
|
|
671
|
+
<p className="font-medium truncate text-[11px]">{att.name}</p>
|
|
672
|
+
<p className="text-[10px] opacity-75">{formatFileSize(att.size)}</p>
|
|
673
|
+
</div>
|
|
674
|
+
<Download className="w-3.5 h-3.5 opacity-70 shrink-0" />
|
|
675
|
+
</a>
|
|
676
|
+
);
|
|
677
|
+
})}
|
|
678
|
+
</div>
|
|
679
|
+
)}
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
</div>
|
|
683
|
+
);
|
|
684
|
+
}
|