@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,909 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { useState, useTransition } from "react"
|
|
5
|
+
import { useRouter } from "next/navigation"
|
|
6
|
+
import {
|
|
7
|
+
Table,
|
|
8
|
+
TableHeader,
|
|
9
|
+
TableBody,
|
|
10
|
+
TableFooter,
|
|
11
|
+
TableHead,
|
|
12
|
+
TableRow,
|
|
13
|
+
TableCell,
|
|
14
|
+
} from "@/components/ui/table"
|
|
15
|
+
import { Button } from "@/components/ui/button"
|
|
16
|
+
import { Input } from "@/components/ui/input"
|
|
17
|
+
import {
|
|
18
|
+
Select,
|
|
19
|
+
SelectContent,
|
|
20
|
+
SelectItem,
|
|
21
|
+
SelectTrigger,
|
|
22
|
+
SelectValue,
|
|
23
|
+
} from "@/components/ui/select"
|
|
24
|
+
import {
|
|
25
|
+
Card,
|
|
26
|
+
CardHeader,
|
|
27
|
+
CardTitle,
|
|
28
|
+
CardDescription,
|
|
29
|
+
CardContent,
|
|
30
|
+
} from "@/components/ui/card"
|
|
31
|
+
import { Badge } from "@/components/ui/badge"
|
|
32
|
+
import {
|
|
33
|
+
addMaintenancePart,
|
|
34
|
+
deleteMaintenancePart,
|
|
35
|
+
addMaintenanceCost,
|
|
36
|
+
deleteMaintenanceCost,
|
|
37
|
+
} from "@/lib/actions/checklists"
|
|
38
|
+
import { cn } from "@/lib/utils"
|
|
39
|
+
import {
|
|
40
|
+
Plus,
|
|
41
|
+
Trash2,
|
|
42
|
+
Package,
|
|
43
|
+
Receipt,
|
|
44
|
+
DollarSign,
|
|
45
|
+
AlertCircle,
|
|
46
|
+
X,
|
|
47
|
+
Check,
|
|
48
|
+
Calculator,
|
|
49
|
+
Loader2,
|
|
50
|
+
} from "lucide-react"
|
|
51
|
+
|
|
52
|
+
// ── Types & Helpers ──────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export interface MaintenancePartItem {
|
|
55
|
+
id: string
|
|
56
|
+
maintenanceTaskId?: string
|
|
57
|
+
maintenanceRecordId?: string | null
|
|
58
|
+
partNumber: string
|
|
59
|
+
partName: string
|
|
60
|
+
quantity: number
|
|
61
|
+
unitCost?: number | null
|
|
62
|
+
currency?: string | null
|
|
63
|
+
supplierName?: string | null
|
|
64
|
+
notes?: string | null
|
|
65
|
+
createdAt?: Date | string | null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface MaintenanceCostItem {
|
|
69
|
+
id: string
|
|
70
|
+
maintenanceTaskId?: string
|
|
71
|
+
maintenanceRecordId?: string | null
|
|
72
|
+
costType: "part" | "vendor_service" | "labor" | "transport" | "other" | string
|
|
73
|
+
amount: number
|
|
74
|
+
currency?: string | null
|
|
75
|
+
vendorName?: string | null
|
|
76
|
+
invoiceNumber?: string | null
|
|
77
|
+
description?: string | null
|
|
78
|
+
recordedAt?: Date | string | null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const COST_TYPE_LABELS: Record<string, string> = {
|
|
82
|
+
part: "Part / Consumable",
|
|
83
|
+
vendor_service: "Vendor Service",
|
|
84
|
+
labor: "Labor",
|
|
85
|
+
transport: "Transport / Freight",
|
|
86
|
+
other: "Other",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const COST_TYPE_BADGES: Record<string, string> = {
|
|
90
|
+
part: "bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-800",
|
|
91
|
+
vendor_service: "bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-800",
|
|
92
|
+
labor: "bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-800",
|
|
93
|
+
transport: "bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-800",
|
|
94
|
+
other: "bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Formats an integer amount in cents into currency (e.g. 1250 cents -> "$12.50").
|
|
99
|
+
*/
|
|
100
|
+
export function formatCurrency(cents?: number | null, currency = "USD"): string {
|
|
101
|
+
if (cents === undefined || cents === null || isNaN(cents)) return "$0.00"
|
|
102
|
+
const dollars = cents / 100
|
|
103
|
+
try {
|
|
104
|
+
return new Intl.NumberFormat("en-US", {
|
|
105
|
+
style: "currency",
|
|
106
|
+
currency: currency || "USD",
|
|
107
|
+
minimumFractionDigits: 2,
|
|
108
|
+
maximumFractionDigits: 2,
|
|
109
|
+
}).format(dollars)
|
|
110
|
+
} catch {
|
|
111
|
+
return `$${dollars.toFixed(2)}`
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── 1. PartsManager Component ─────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export interface PartsManagerProps {
|
|
118
|
+
taskId: string
|
|
119
|
+
parts: MaintenancePartItem[] | any[]
|
|
120
|
+
editable: boolean
|
|
121
|
+
className?: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function PartsManager({
|
|
125
|
+
taskId,
|
|
126
|
+
parts = [],
|
|
127
|
+
editable = true,
|
|
128
|
+
className,
|
|
129
|
+
}: PartsManagerProps) {
|
|
130
|
+
const router = useRouter()
|
|
131
|
+
const [isPending, startTransition] = useTransition()
|
|
132
|
+
const [isAdding, setIsAdding] = useState(false)
|
|
133
|
+
const [deletingId, setDeletingId] = useState<string | null>(null)
|
|
134
|
+
const [error, setError] = useState<string | null>(null)
|
|
135
|
+
|
|
136
|
+
// Inline form state
|
|
137
|
+
const [partNumber, setPartNumber] = useState("")
|
|
138
|
+
const [partName, setPartName] = useState("")
|
|
139
|
+
const [quantity, setQuantity] = useState("1")
|
|
140
|
+
const [unitCostInput, setUnitCostInput] = useState("")
|
|
141
|
+
const [supplierName, setSupplierName] = useState("")
|
|
142
|
+
const [notes, setNotes] = useState("")
|
|
143
|
+
|
|
144
|
+
const resetForm = () => {
|
|
145
|
+
setPartNumber("")
|
|
146
|
+
setPartName("")
|
|
147
|
+
setQuantity("1")
|
|
148
|
+
setUnitCostInput("")
|
|
149
|
+
setSupplierName("")
|
|
150
|
+
setNotes("")
|
|
151
|
+
setError(null)
|
|
152
|
+
setIsAdding(false)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Calculate live total for parts
|
|
156
|
+
const totalPartsCost = parts.reduce((sum, p) => {
|
|
157
|
+
const qty = Number(p.quantity) || 1
|
|
158
|
+
const cost = Number(p.unitCost) || 0
|
|
159
|
+
return sum + cost * qty
|
|
160
|
+
}, 0)
|
|
161
|
+
|
|
162
|
+
const handleAddPart = async (e: React.FormEvent) => {
|
|
163
|
+
e.preventDefault()
|
|
164
|
+
if (!partNumber.trim()) {
|
|
165
|
+
setError("Part number is required")
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
if (!partName.trim()) {
|
|
169
|
+
setError("Part name is required")
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const qtyNum = parseInt(quantity, 10)
|
|
174
|
+
if (isNaN(qtyNum) || qtyNum < 1) {
|
|
175
|
+
setError("Quantity must be at least 1")
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let unitCostCents: number | undefined = undefined
|
|
180
|
+
if (unitCostInput.trim() !== "") {
|
|
181
|
+
const parsedDollars = parseFloat(unitCostInput)
|
|
182
|
+
if (isNaN(parsedDollars) || parsedDollars < 0) {
|
|
183
|
+
setError("Unit cost must be a valid non-negative number")
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
unitCostCents = Math.round(parsedDollars * 100)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
setError(null)
|
|
190
|
+
|
|
191
|
+
startTransition(async () => {
|
|
192
|
+
const res = await addMaintenancePart(taskId, {
|
|
193
|
+
partNumber: partNumber.trim(),
|
|
194
|
+
partName: partName.trim(),
|
|
195
|
+
quantity: qtyNum,
|
|
196
|
+
unitCost: unitCostCents,
|
|
197
|
+
currency: "USD",
|
|
198
|
+
supplierName: supplierName.trim() || undefined,
|
|
199
|
+
notes: notes.trim() || undefined,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
if (res?.success) {
|
|
203
|
+
resetForm()
|
|
204
|
+
router.refresh()
|
|
205
|
+
} else {
|
|
206
|
+
setError(res?.error || "Failed to add spare part")
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const handleDeletePart = async (partId: string) => {
|
|
212
|
+
setError(null)
|
|
213
|
+
setDeletingId(partId)
|
|
214
|
+
|
|
215
|
+
startTransition(async () => {
|
|
216
|
+
const res = await deleteMaintenancePart(partId)
|
|
217
|
+
if (res?.success) {
|
|
218
|
+
router.refresh()
|
|
219
|
+
} else {
|
|
220
|
+
setError(res?.error || "Failed to delete spare part")
|
|
221
|
+
}
|
|
222
|
+
setDeletingId(null)
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div className={cn("space-y-4", className)}>
|
|
228
|
+
<div className="flex items-center justify-between">
|
|
229
|
+
<div className="flex items-center gap-2">
|
|
230
|
+
<Package className="h-5 w-5 text-primary" />
|
|
231
|
+
<h3 className="text-base font-semibold text-foreground">Spare Parts Used</h3>
|
|
232
|
+
<Badge variant="secondary" className="text-xs font-medium">
|
|
233
|
+
{parts.length} {parts.length === 1 ? "item" : "items"}
|
|
234
|
+
</Badge>
|
|
235
|
+
</div>
|
|
236
|
+
{editable && !isAdding && (
|
|
237
|
+
<Button
|
|
238
|
+
type="button"
|
|
239
|
+
size="sm"
|
|
240
|
+
onClick={() => {
|
|
241
|
+
setError(null)
|
|
242
|
+
setIsAdding(true)
|
|
243
|
+
}}
|
|
244
|
+
className="h-8 gap-1 text-xs"
|
|
245
|
+
>
|
|
246
|
+
<Plus className="h-3.5 w-3.5" />
|
|
247
|
+
Add Part
|
|
248
|
+
</Button>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
{error && (
|
|
253
|
+
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive">
|
|
254
|
+
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
255
|
+
<span>{error}</span>
|
|
256
|
+
<Button
|
|
257
|
+
type="button"
|
|
258
|
+
variant="ghost"
|
|
259
|
+
size="icon"
|
|
260
|
+
onClick={() => setError(null)}
|
|
261
|
+
className="ml-auto h-5 w-5 text-destructive hover:bg-transparent"
|
|
262
|
+
>
|
|
263
|
+
<X className="h-3 w-3" />
|
|
264
|
+
</Button>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
<div className="overflow-hidden rounded-lg border bg-card shadow-sm">
|
|
269
|
+
<Table>
|
|
270
|
+
<TableHeader>
|
|
271
|
+
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
|
272
|
+
<TableHead className="w-[140px] font-semibold">Part #</TableHead>
|
|
273
|
+
<TableHead className="font-semibold">Part Name</TableHead>
|
|
274
|
+
<TableHead className="w-[70px] text-center font-semibold">Qty</TableHead>
|
|
275
|
+
<TableHead className="w-[110px] text-right font-semibold">Unit Cost</TableHead>
|
|
276
|
+
<TableHead className="w-[110px] text-right font-semibold">Total</TableHead>
|
|
277
|
+
<TableHead className="w-[160px] font-semibold">Supplier</TableHead>
|
|
278
|
+
{editable && <TableHead className="w-[60px] text-center font-semibold">Action</TableHead>}
|
|
279
|
+
</TableRow>
|
|
280
|
+
</TableHeader>
|
|
281
|
+
<TableBody>
|
|
282
|
+
{parts.length === 0 && !isAdding ? (
|
|
283
|
+
<TableRow>
|
|
284
|
+
<TableCell
|
|
285
|
+
colSpan={editable ? 7 : 6}
|
|
286
|
+
className="h-24 text-center text-sm text-muted-foreground"
|
|
287
|
+
>
|
|
288
|
+
No spare parts recorded for this task.
|
|
289
|
+
</TableCell>
|
|
290
|
+
</TableRow>
|
|
291
|
+
) : (
|
|
292
|
+
parts.map((part) => {
|
|
293
|
+
const qty = Number(part.quantity) || 1
|
|
294
|
+
const unitCost = Number(part.unitCost) || 0
|
|
295
|
+
const rowTotal = qty * unitCost
|
|
296
|
+
const isDeleting = deletingId === part.id
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<TableRow key={part.id} className="hover:bg-muted/30">
|
|
300
|
+
<TableCell className="font-mono text-xs font-medium text-foreground">
|
|
301
|
+
{part.partNumber}
|
|
302
|
+
</TableCell>
|
|
303
|
+
<TableCell>
|
|
304
|
+
<div className="text-sm font-medium text-foreground">{part.partName}</div>
|
|
305
|
+
{part.notes && (
|
|
306
|
+
<div className="text-xs text-muted-foreground line-clamp-1">{part.notes}</div>
|
|
307
|
+
)}
|
|
308
|
+
</TableCell>
|
|
309
|
+
<TableCell className="text-center text-sm font-medium">
|
|
310
|
+
{qty}
|
|
311
|
+
</TableCell>
|
|
312
|
+
<TableCell className="text-right font-mono text-xs text-muted-foreground">
|
|
313
|
+
{formatCurrency(part.unitCost, part.currency || "USD")}
|
|
314
|
+
</TableCell>
|
|
315
|
+
<TableCell className="text-right font-mono text-xs font-semibold text-foreground">
|
|
316
|
+
{formatCurrency(rowTotal, part.currency || "USD")}
|
|
317
|
+
</TableCell>
|
|
318
|
+
<TableCell className="text-xs text-muted-foreground">
|
|
319
|
+
{part.supplierName || "—"}
|
|
320
|
+
</TableCell>
|
|
321
|
+
{editable && (
|
|
322
|
+
<TableCell className="text-center">
|
|
323
|
+
<Button
|
|
324
|
+
type="button"
|
|
325
|
+
variant="ghost"
|
|
326
|
+
size="icon"
|
|
327
|
+
disabled={isDeleting || isPending}
|
|
328
|
+
onClick={() => handleDeletePart(part.id)}
|
|
329
|
+
className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
|
330
|
+
title="Delete part"
|
|
331
|
+
>
|
|
332
|
+
{isDeleting ? (
|
|
333
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
334
|
+
) : (
|
|
335
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
336
|
+
)}
|
|
337
|
+
</Button>
|
|
338
|
+
</TableCell>
|
|
339
|
+
)}
|
|
340
|
+
</TableRow>
|
|
341
|
+
)
|
|
342
|
+
})
|
|
343
|
+
)}
|
|
344
|
+
|
|
345
|
+
{/* Inline Add Part Form */}
|
|
346
|
+
{isAdding && (
|
|
347
|
+
<TableRow className="bg-primary/5 border-primary/20">
|
|
348
|
+
<TableCell className="p-2">
|
|
349
|
+
<Input
|
|
350
|
+
placeholder="Part #"
|
|
351
|
+
value={partNumber}
|
|
352
|
+
onChange={(e) => setPartNumber(e.target.value)}
|
|
353
|
+
disabled={isPending}
|
|
354
|
+
className="h-8 text-xs font-mono"
|
|
355
|
+
autoFocus
|
|
356
|
+
/>
|
|
357
|
+
</TableCell>
|
|
358
|
+
<TableCell className="p-2">
|
|
359
|
+
<Input
|
|
360
|
+
placeholder="Part Name"
|
|
361
|
+
value={partName}
|
|
362
|
+
onChange={(e) => setPartName(e.target.value)}
|
|
363
|
+
disabled={isPending}
|
|
364
|
+
className="h-8 text-xs"
|
|
365
|
+
/>
|
|
366
|
+
</TableCell>
|
|
367
|
+
<TableCell className="p-2">
|
|
368
|
+
<Input
|
|
369
|
+
type="number"
|
|
370
|
+
min="1"
|
|
371
|
+
placeholder="1"
|
|
372
|
+
value={quantity}
|
|
373
|
+
onChange={(e) => setQuantity(e.target.value)}
|
|
374
|
+
disabled={isPending}
|
|
375
|
+
className="h-8 text-xs text-center"
|
|
376
|
+
/>
|
|
377
|
+
</TableCell>
|
|
378
|
+
<TableCell className="p-2">
|
|
379
|
+
<Input
|
|
380
|
+
type="number"
|
|
381
|
+
step="0.01"
|
|
382
|
+
min="0"
|
|
383
|
+
placeholder="0.00"
|
|
384
|
+
value={unitCostInput}
|
|
385
|
+
onChange={(e) => setUnitCostInput(e.target.value)}
|
|
386
|
+
disabled={isPending}
|
|
387
|
+
className="h-8 text-xs text-right font-mono"
|
|
388
|
+
/>
|
|
389
|
+
</TableCell>
|
|
390
|
+
<TableCell className="p-2 text-right font-mono text-xs font-semibold text-muted-foreground">
|
|
391
|
+
{formatCurrency(
|
|
392
|
+
(parseInt(quantity || "0", 10) || 0) *
|
|
393
|
+
Math.round((parseFloat(unitCostInput || "0") || 0) * 100)
|
|
394
|
+
)}
|
|
395
|
+
</TableCell>
|
|
396
|
+
<TableCell className="p-2">
|
|
397
|
+
<Input
|
|
398
|
+
placeholder="Supplier (optional)"
|
|
399
|
+
value={supplierName}
|
|
400
|
+
onChange={(e) => setSupplierName(e.target.value)}
|
|
401
|
+
disabled={isPending}
|
|
402
|
+
className="h-8 text-xs"
|
|
403
|
+
/>
|
|
404
|
+
</TableCell>
|
|
405
|
+
<TableCell className="p-2 text-center">
|
|
406
|
+
<div className="flex items-center justify-center gap-1">
|
|
407
|
+
<Button
|
|
408
|
+
type="button"
|
|
409
|
+
size="icon"
|
|
410
|
+
variant="default"
|
|
411
|
+
disabled={isPending}
|
|
412
|
+
onClick={handleAddPart}
|
|
413
|
+
className="h-7 w-7 bg-primary text-primary-foreground"
|
|
414
|
+
title="Save part"
|
|
415
|
+
>
|
|
416
|
+
{isPending ? (
|
|
417
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
418
|
+
) : (
|
|
419
|
+
<Check className="h-3.5 w-3.5" />
|
|
420
|
+
)}
|
|
421
|
+
</Button>
|
|
422
|
+
<Button
|
|
423
|
+
type="button"
|
|
424
|
+
size="icon"
|
|
425
|
+
variant="ghost"
|
|
426
|
+
disabled={isPending}
|
|
427
|
+
onClick={resetForm}
|
|
428
|
+
className="h-7 w-7 text-muted-foreground hover:bg-muted"
|
|
429
|
+
title="Cancel"
|
|
430
|
+
>
|
|
431
|
+
<X className="h-3.5 w-3.5" />
|
|
432
|
+
</Button>
|
|
433
|
+
</div>
|
|
434
|
+
</TableCell>
|
|
435
|
+
</TableRow>
|
|
436
|
+
)}
|
|
437
|
+
</TableBody>
|
|
438
|
+
|
|
439
|
+
<TableFooter>
|
|
440
|
+
<TableRow className="bg-muted/30 font-semibold">
|
|
441
|
+
<TableCell colSpan={4} className="text-right text-xs uppercase tracking-wider text-muted-foreground">
|
|
442
|
+
Total Spare Parts Cost:
|
|
443
|
+
</TableCell>
|
|
444
|
+
<TableCell className="text-right font-mono text-sm font-bold text-foreground">
|
|
445
|
+
{formatCurrency(totalPartsCost)}
|
|
446
|
+
</TableCell>
|
|
447
|
+
<TableCell colSpan={editable ? 2 : 1} />
|
|
448
|
+
</TableRow>
|
|
449
|
+
</TableFooter>
|
|
450
|
+
</Table>
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── 2. CostsManager Component ─────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
export interface CostsManagerProps {
|
|
459
|
+
taskId: string
|
|
460
|
+
costs: MaintenanceCostItem[] | any[]
|
|
461
|
+
editable: boolean
|
|
462
|
+
className?: string
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function CostsManager({
|
|
466
|
+
taskId,
|
|
467
|
+
costs = [],
|
|
468
|
+
editable = true,
|
|
469
|
+
className,
|
|
470
|
+
}: CostsManagerProps) {
|
|
471
|
+
const router = useRouter()
|
|
472
|
+
const [isPending, startTransition] = useTransition()
|
|
473
|
+
const [isAdding, setIsAdding] = useState(false)
|
|
474
|
+
const [deletingId, setDeletingId] = useState<string | null>(null)
|
|
475
|
+
const [error, setError] = useState<string | null>(null)
|
|
476
|
+
|
|
477
|
+
// Inline form state
|
|
478
|
+
const [costType, setCostType] = useState<string>("vendor_service")
|
|
479
|
+
const [amountInput, setAmountInput] = useState("")
|
|
480
|
+
const [vendorName, setVendorName] = useState("")
|
|
481
|
+
const [invoiceNumber, setInvoiceNumber] = useState("")
|
|
482
|
+
const [description, setDescription] = useState("")
|
|
483
|
+
|
|
484
|
+
const resetForm = () => {
|
|
485
|
+
setCostType("vendor_service")
|
|
486
|
+
setAmountInput("")
|
|
487
|
+
setVendorName("")
|
|
488
|
+
setInvoiceNumber("")
|
|
489
|
+
setDescription("")
|
|
490
|
+
setError(null)
|
|
491
|
+
setIsAdding(false)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Calculate live total for additional costs
|
|
495
|
+
const totalOtherCosts = costs.reduce((sum, c) => sum + (Number(c.amount) || 0), 0)
|
|
496
|
+
|
|
497
|
+
const handleAddCost = async (e: React.FormEvent) => {
|
|
498
|
+
e.preventDefault()
|
|
499
|
+
if (!amountInput.trim()) {
|
|
500
|
+
setError("Cost amount is required")
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const parsedDollars = parseFloat(amountInput)
|
|
505
|
+
if (isNaN(parsedDollars) || parsedDollars < 0) {
|
|
506
|
+
setError("Amount must be a valid non-negative number")
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const amountCents = Math.round(parsedDollars * 100)
|
|
511
|
+
setError(null)
|
|
512
|
+
|
|
513
|
+
startTransition(async () => {
|
|
514
|
+
const res = await addMaintenanceCost(taskId, {
|
|
515
|
+
costType: costType as any,
|
|
516
|
+
amount: amountCents,
|
|
517
|
+
currency: "USD",
|
|
518
|
+
vendorName: vendorName.trim() || undefined,
|
|
519
|
+
invoiceNumber: invoiceNumber.trim() || undefined,
|
|
520
|
+
description: description.trim() || undefined,
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
if (res?.success) {
|
|
524
|
+
resetForm()
|
|
525
|
+
router.refresh()
|
|
526
|
+
} else {
|
|
527
|
+
setError(res?.error || "Failed to add cost item")
|
|
528
|
+
}
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const handleDeleteCost = async (costId: string) => {
|
|
533
|
+
setError(null)
|
|
534
|
+
setDeletingId(costId)
|
|
535
|
+
|
|
536
|
+
startTransition(async () => {
|
|
537
|
+
const res = await deleteMaintenanceCost(costId)
|
|
538
|
+
if (res?.success) {
|
|
539
|
+
router.refresh()
|
|
540
|
+
} else {
|
|
541
|
+
setError(res?.error || "Failed to delete cost item")
|
|
542
|
+
}
|
|
543
|
+
setDeletingId(null)
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return (
|
|
548
|
+
<div className={cn("space-y-4", className)}>
|
|
549
|
+
<div className="flex items-center justify-between">
|
|
550
|
+
<div className="flex items-center gap-2">
|
|
551
|
+
<Receipt className="h-5 w-5 text-primary" />
|
|
552
|
+
<h3 className="text-base font-semibold text-foreground">Additional Service & Labor Costs</h3>
|
|
553
|
+
<Badge variant="secondary" className="text-xs font-medium">
|
|
554
|
+
{costs.length} {costs.length === 1 ? "entry" : "entries"}
|
|
555
|
+
</Badge>
|
|
556
|
+
</div>
|
|
557
|
+
{editable && !isAdding && (
|
|
558
|
+
<Button
|
|
559
|
+
type="button"
|
|
560
|
+
size="sm"
|
|
561
|
+
onClick={() => {
|
|
562
|
+
setError(null)
|
|
563
|
+
setIsAdding(true)
|
|
564
|
+
}}
|
|
565
|
+
className="h-8 gap-1 text-xs"
|
|
566
|
+
>
|
|
567
|
+
<Plus className="h-3.5 w-3.5" />
|
|
568
|
+
Add Cost
|
|
569
|
+
</Button>
|
|
570
|
+
)}
|
|
571
|
+
</div>
|
|
572
|
+
|
|
573
|
+
{error && (
|
|
574
|
+
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive">
|
|
575
|
+
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
576
|
+
<span>{error}</span>
|
|
577
|
+
<Button
|
|
578
|
+
type="button"
|
|
579
|
+
variant="ghost"
|
|
580
|
+
size="icon"
|
|
581
|
+
onClick={() => setError(null)}
|
|
582
|
+
className="ml-auto h-5 w-5 text-destructive hover:bg-transparent"
|
|
583
|
+
>
|
|
584
|
+
<X className="h-3 w-3" />
|
|
585
|
+
</Button>
|
|
586
|
+
</div>
|
|
587
|
+
)}
|
|
588
|
+
|
|
589
|
+
<div className="overflow-hidden rounded-lg border bg-card shadow-sm">
|
|
590
|
+
<Table>
|
|
591
|
+
<TableHeader>
|
|
592
|
+
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
|
593
|
+
<TableHead className="w-[160px] font-semibold">Cost Type</TableHead>
|
|
594
|
+
<TableHead className="w-[120px] text-right font-semibold">Amount</TableHead>
|
|
595
|
+
<TableHead className="w-[180px] font-semibold">Vendor / Provider</TableHead>
|
|
596
|
+
<TableHead className="w-[140px] font-semibold">Invoice #</TableHead>
|
|
597
|
+
<TableHead className="font-semibold">Description</TableHead>
|
|
598
|
+
{editable && <TableHead className="w-[60px] text-center font-semibold">Action</TableHead>}
|
|
599
|
+
</TableRow>
|
|
600
|
+
</TableHeader>
|
|
601
|
+
<TableBody>
|
|
602
|
+
{costs.length === 0 && !isAdding ? (
|
|
603
|
+
<TableRow>
|
|
604
|
+
<TableCell
|
|
605
|
+
colSpan={editable ? 6 : 5}
|
|
606
|
+
className="h-24 text-center text-sm text-muted-foreground"
|
|
607
|
+
>
|
|
608
|
+
No additional service or labor costs recorded.
|
|
609
|
+
</TableCell>
|
|
610
|
+
</TableRow>
|
|
611
|
+
) : (
|
|
612
|
+
costs.map((cost) => {
|
|
613
|
+
const isDeleting = deletingId === cost.id
|
|
614
|
+
const badgeColor = COST_TYPE_BADGES[cost.costType] || "bg-muted text-muted-foreground"
|
|
615
|
+
const typeLabel = COST_TYPE_LABELS[cost.costType] || cost.costType
|
|
616
|
+
|
|
617
|
+
return (
|
|
618
|
+
<TableRow key={cost.id} className="hover:bg-muted/30">
|
|
619
|
+
<TableCell>
|
|
620
|
+
<Badge variant="outline" className={cn("text-xs font-medium", badgeColor)}>
|
|
621
|
+
{typeLabel}
|
|
622
|
+
</Badge>
|
|
623
|
+
</TableCell>
|
|
624
|
+
<TableCell className="text-right font-mono text-xs font-semibold text-foreground">
|
|
625
|
+
{formatCurrency(cost.amount, cost.currency || "USD")}
|
|
626
|
+
</TableCell>
|
|
627
|
+
<TableCell className="text-xs font-medium text-foreground">
|
|
628
|
+
{cost.vendorName || "—"}
|
|
629
|
+
</TableCell>
|
|
630
|
+
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
631
|
+
{cost.invoiceNumber || "—"}
|
|
632
|
+
</TableCell>
|
|
633
|
+
<TableCell className="text-xs text-muted-foreground">
|
|
634
|
+
{cost.description || "—"}
|
|
635
|
+
</TableCell>
|
|
636
|
+
{editable && (
|
|
637
|
+
<TableCell className="text-center">
|
|
638
|
+
<Button
|
|
639
|
+
type="button"
|
|
640
|
+
variant="ghost"
|
|
641
|
+
size="icon"
|
|
642
|
+
disabled={isDeleting || isPending}
|
|
643
|
+
onClick={() => handleDeleteCost(cost.id)}
|
|
644
|
+
className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
|
645
|
+
title="Delete cost entry"
|
|
646
|
+
>
|
|
647
|
+
{isDeleting ? (
|
|
648
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
649
|
+
) : (
|
|
650
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
651
|
+
)}
|
|
652
|
+
</Button>
|
|
653
|
+
</TableCell>
|
|
654
|
+
)}
|
|
655
|
+
</TableRow>
|
|
656
|
+
)
|
|
657
|
+
})
|
|
658
|
+
)}
|
|
659
|
+
|
|
660
|
+
{/* Inline Add Cost Form */}
|
|
661
|
+
{isAdding && (
|
|
662
|
+
<TableRow className="bg-primary/5 border-primary/20">
|
|
663
|
+
<TableCell className="p-2">
|
|
664
|
+
<Select value={costType} onValueChange={setCostType} disabled={isPending}>
|
|
665
|
+
<SelectTrigger className="h-8 text-xs bg-background">
|
|
666
|
+
<SelectValue placeholder="Select type" />
|
|
667
|
+
</SelectTrigger>
|
|
668
|
+
<SelectContent>
|
|
669
|
+
<SelectItem value="vendor_service">Vendor Service</SelectItem>
|
|
670
|
+
<SelectItem value="labor">Labor</SelectItem>
|
|
671
|
+
<SelectItem value="part">Part / Consumable</SelectItem>
|
|
672
|
+
<SelectItem value="transport">Transport</SelectItem>
|
|
673
|
+
<SelectItem value="other">Other</SelectItem>
|
|
674
|
+
</SelectContent>
|
|
675
|
+
</Select>
|
|
676
|
+
</TableCell>
|
|
677
|
+
<TableCell className="p-2">
|
|
678
|
+
<Input
|
|
679
|
+
type="number"
|
|
680
|
+
step="0.01"
|
|
681
|
+
min="0"
|
|
682
|
+
placeholder="0.00"
|
|
683
|
+
value={amountInput}
|
|
684
|
+
onChange={(e) => setAmountInput(e.target.value)}
|
|
685
|
+
disabled={isPending}
|
|
686
|
+
className="h-8 text-xs text-right font-mono bg-background"
|
|
687
|
+
autoFocus
|
|
688
|
+
/>
|
|
689
|
+
</TableCell>
|
|
690
|
+
<TableCell className="p-2">
|
|
691
|
+
<Input
|
|
692
|
+
placeholder="Vendor / Provider"
|
|
693
|
+
value={vendorName}
|
|
694
|
+
onChange={(e) => setVendorName(e.target.value)}
|
|
695
|
+
disabled={isPending}
|
|
696
|
+
className="h-8 text-xs bg-background"
|
|
697
|
+
/>
|
|
698
|
+
</TableCell>
|
|
699
|
+
<TableCell className="p-2">
|
|
700
|
+
<Input
|
|
701
|
+
placeholder="Invoice #"
|
|
702
|
+
value={invoiceNumber}
|
|
703
|
+
onChange={(e) => setInvoiceNumber(e.target.value)}
|
|
704
|
+
disabled={isPending}
|
|
705
|
+
className="h-8 text-xs font-mono bg-background"
|
|
706
|
+
/>
|
|
707
|
+
</TableCell>
|
|
708
|
+
<TableCell className="p-2">
|
|
709
|
+
<Input
|
|
710
|
+
placeholder="Cost description / notes"
|
|
711
|
+
value={description}
|
|
712
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
713
|
+
disabled={isPending}
|
|
714
|
+
className="h-8 text-xs bg-background"
|
|
715
|
+
/>
|
|
716
|
+
</TableCell>
|
|
717
|
+
<TableCell className="p-2 text-center">
|
|
718
|
+
<div className="flex items-center justify-center gap-1">
|
|
719
|
+
<Button
|
|
720
|
+
type="button"
|
|
721
|
+
size="icon"
|
|
722
|
+
variant="default"
|
|
723
|
+
disabled={isPending}
|
|
724
|
+
onClick={handleAddCost}
|
|
725
|
+
className="h-7 w-7 bg-primary text-primary-foreground"
|
|
726
|
+
title="Save cost"
|
|
727
|
+
>
|
|
728
|
+
{isPending ? (
|
|
729
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
730
|
+
) : (
|
|
731
|
+
<Check className="h-3.5 w-3.5" />
|
|
732
|
+
)}
|
|
733
|
+
</Button>
|
|
734
|
+
<Button
|
|
735
|
+
type="button"
|
|
736
|
+
size="icon"
|
|
737
|
+
variant="ghost"
|
|
738
|
+
disabled={isPending}
|
|
739
|
+
onClick={resetForm}
|
|
740
|
+
className="h-7 w-7 text-muted-foreground hover:bg-muted"
|
|
741
|
+
title="Cancel"
|
|
742
|
+
>
|
|
743
|
+
<X className="h-3.5 w-3.5" />
|
|
744
|
+
</Button>
|
|
745
|
+
</div>
|
|
746
|
+
</TableCell>
|
|
747
|
+
</TableRow>
|
|
748
|
+
)}
|
|
749
|
+
</TableBody>
|
|
750
|
+
|
|
751
|
+
<TableFooter>
|
|
752
|
+
<TableRow className="bg-muted/30 font-semibold">
|
|
753
|
+
<TableCell className="text-right text-xs uppercase tracking-wider text-muted-foreground">
|
|
754
|
+
Total Other Costs:
|
|
755
|
+
</TableCell>
|
|
756
|
+
<TableCell className="text-right font-mono text-sm font-bold text-foreground">
|
|
757
|
+
{formatCurrency(totalOtherCosts)}
|
|
758
|
+
</TableCell>
|
|
759
|
+
<TableCell colSpan={editable ? 4 : 3} />
|
|
760
|
+
</TableRow>
|
|
761
|
+
</TableFooter>
|
|
762
|
+
</Table>
|
|
763
|
+
</div>
|
|
764
|
+
</div>
|
|
765
|
+
)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ── 3. PartsCostsSummary Component ────────────────────────────────────────────
|
|
769
|
+
|
|
770
|
+
export interface PartsCostsSummaryProps {
|
|
771
|
+
parts?: MaintenancePartItem[] | any[]
|
|
772
|
+
costs?: MaintenanceCostItem[] | any[]
|
|
773
|
+
totalPartsCost?: number
|
|
774
|
+
totalOtherCosts?: number
|
|
775
|
+
grandTotal?: number
|
|
776
|
+
currency?: string
|
|
777
|
+
className?: string
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export function PartsCostsSummary({
|
|
781
|
+
parts = [],
|
|
782
|
+
costs = [],
|
|
783
|
+
totalPartsCost,
|
|
784
|
+
totalOtherCosts,
|
|
785
|
+
grandTotal,
|
|
786
|
+
currency = "USD",
|
|
787
|
+
className,
|
|
788
|
+
}: PartsCostsSummaryProps) {
|
|
789
|
+
// Compute totals if not provided explicitly
|
|
790
|
+
const calculatedParts =
|
|
791
|
+
totalPartsCost !== undefined
|
|
792
|
+
? totalPartsCost
|
|
793
|
+
: parts.reduce((sum, p) => sum + (Number(p.unitCost) || 0) * (Number(p.quantity) || 1), 0)
|
|
794
|
+
|
|
795
|
+
const calculatedCosts =
|
|
796
|
+
totalOtherCosts !== undefined
|
|
797
|
+
? totalOtherCosts
|
|
798
|
+
: costs.reduce((sum, c) => sum + (Number(c.amount) || 0), 0)
|
|
799
|
+
|
|
800
|
+
const calculatedGrandTotal =
|
|
801
|
+
grandTotal !== undefined ? grandTotal : calculatedParts + calculatedCosts
|
|
802
|
+
|
|
803
|
+
const partsCount = parts.length
|
|
804
|
+
const costsCount = costs.length
|
|
805
|
+
|
|
806
|
+
return (
|
|
807
|
+
<Card className={cn("overflow-hidden border-border shadow-sm", className)}>
|
|
808
|
+
<CardHeader className="bg-muted/40 border-b pb-3 pt-4">
|
|
809
|
+
<div className="flex items-center justify-between">
|
|
810
|
+
<div className="flex items-center gap-2">
|
|
811
|
+
<Calculator className="h-4 w-4 text-primary" />
|
|
812
|
+
<CardTitle className="text-sm font-semibold tracking-tight text-foreground">
|
|
813
|
+
Cost & Resource Summary
|
|
814
|
+
</CardTitle>
|
|
815
|
+
</div>
|
|
816
|
+
<Badge variant="outline" className="font-mono text-xs">
|
|
817
|
+
{currency}
|
|
818
|
+
</Badge>
|
|
819
|
+
</div>
|
|
820
|
+
<CardDescription className="text-xs">
|
|
821
|
+
Financial total for parts and service execution
|
|
822
|
+
</CardDescription>
|
|
823
|
+
</CardHeader>
|
|
824
|
+
|
|
825
|
+
<CardContent className="p-4 pt-4">
|
|
826
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
827
|
+
{/* Total Parts */}
|
|
828
|
+
<div className="rounded-lg border bg-background p-3">
|
|
829
|
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
830
|
+
<span className="flex items-center gap-1.5 font-medium">
|
|
831
|
+
<Package className="h-3.5 w-3.5 text-blue-500" />
|
|
832
|
+
Spare Parts
|
|
833
|
+
</span>
|
|
834
|
+
<span className="text-[11px]">
|
|
835
|
+
{partsCount} {partsCount === 1 ? "item" : "items"}
|
|
836
|
+
</span>
|
|
837
|
+
</div>
|
|
838
|
+
<div className="mt-1.5 font-mono text-lg font-bold text-foreground">
|
|
839
|
+
{formatCurrency(calculatedParts, currency)}
|
|
840
|
+
</div>
|
|
841
|
+
</div>
|
|
842
|
+
|
|
843
|
+
{/* Total Other Costs */}
|
|
844
|
+
<div className="rounded-lg border bg-background p-3">
|
|
845
|
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
846
|
+
<span className="flex items-center gap-1.5 font-medium">
|
|
847
|
+
<Receipt className="h-3.5 w-3.5 text-purple-500" />
|
|
848
|
+
Service & Labor
|
|
849
|
+
</span>
|
|
850
|
+
<span className="text-[11px]">
|
|
851
|
+
{costsCount} {costsCount === 1 ? "entry" : "entries"}
|
|
852
|
+
</span>
|
|
853
|
+
</div>
|
|
854
|
+
<div className="mt-1.5 font-mono text-lg font-bold text-foreground">
|
|
855
|
+
{formatCurrency(calculatedCosts, currency)}
|
|
856
|
+
</div>
|
|
857
|
+
</div>
|
|
858
|
+
|
|
859
|
+
{/* Grand Total */}
|
|
860
|
+
<div className="rounded-lg border border-primary/30 bg-primary/5 p-3 dark:bg-primary/10">
|
|
861
|
+
<div className="flex items-center justify-between text-xs text-primary font-semibold">
|
|
862
|
+
<span className="flex items-center gap-1.5">
|
|
863
|
+
<DollarSign className="h-3.5 w-3.5" />
|
|
864
|
+
Grand Total
|
|
865
|
+
</span>
|
|
866
|
+
<Badge variant="default" className="text-[10px] h-4 px-1 bg-primary">
|
|
867
|
+
Final
|
|
868
|
+
</Badge>
|
|
869
|
+
</div>
|
|
870
|
+
<div className="mt-1.5 font-mono text-lg font-extrabold text-primary">
|
|
871
|
+
{formatCurrency(calculatedGrandTotal, currency)}
|
|
872
|
+
</div>
|
|
873
|
+
</div>
|
|
874
|
+
</div>
|
|
875
|
+
</CardContent>
|
|
876
|
+
</Card>
|
|
877
|
+
)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ── Combined Container Component ──────────────────────────────────────────────
|
|
881
|
+
|
|
882
|
+
export interface PartsCostsManagerCombinedProps {
|
|
883
|
+
taskId: string
|
|
884
|
+
parts?: MaintenancePartItem[] | any[]
|
|
885
|
+
costs?: MaintenanceCostItem[] | any[]
|
|
886
|
+
editable?: boolean
|
|
887
|
+
currency?: string
|
|
888
|
+
className?: string
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export function PartsCostsManager({
|
|
892
|
+
taskId,
|
|
893
|
+
parts = [],
|
|
894
|
+
costs = [],
|
|
895
|
+
editable = true,
|
|
896
|
+
currency = "USD",
|
|
897
|
+
className,
|
|
898
|
+
}: PartsCostsManagerCombinedProps) {
|
|
899
|
+
return (
|
|
900
|
+
<div className={cn("space-y-6", className)}>
|
|
901
|
+
<PartsManager taskId={taskId} parts={parts} editable={editable} />
|
|
902
|
+
<CostsManager taskId={taskId} costs={costs} editable={editable} />
|
|
903
|
+
<PartsCostsSummary parts={parts} costs={costs} currency={currency} />
|
|
904
|
+
</div>
|
|
905
|
+
)
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
export default PartsCostsManager
|
|
909
|
+
|