@omniradiology/omnirad 0.1.3
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/README.md +438 -0
- package/app/api/ai-config/route.ts +131 -0
- package/app/api/ai-config/test/route.ts +49 -0
- package/app/api/auth/auto-login/route.ts +66 -0
- package/app/api/auth/check/route.ts +17 -0
- package/app/api/auth/login/route.ts +72 -0
- package/app/api/auth/logout/route.ts +25 -0
- package/app/api/auth/me/route.ts +75 -0
- package/app/api/auth/password/route.ts +49 -0
- package/app/api/auth/setup/route.ts +63 -0
- package/app/api/auth/users/route.ts +100 -0
- package/app/api/auth/wipe/route.ts +27 -0
- package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
- package/app/api/compliance/audit/route.ts +110 -0
- package/app/api/compliance/export/patient/[id]/route.ts +108 -0
- package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
- package/app/api/compliance/settings/route.ts +93 -0
- package/app/api/copilot/annotate/route.ts +94 -0
- package/app/api/copilot/chat/route.ts +238 -0
- package/app/api/copilot/history/route.ts +95 -0
- package/app/api/copilot/reports/route.ts +81 -0
- package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
- package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
- package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
- package/app/api/fhir/Patient/[id]/route.ts +26 -0
- package/app/api/fhir/ServiceRequest/route.ts +85 -0
- package/app/api/fhir/config/route.ts +102 -0
- package/app/api/fhir/config/test-connection/route.ts +49 -0
- package/app/api/fhir/metadata/route.ts +51 -0
- package/app/api/pacs/metadata/route.ts +32 -0
- package/app/api/pacs/qido/instances/route.ts +39 -0
- package/app/api/pacs/qido/series/route.ts +38 -0
- package/app/api/pacs/qido/studies/route.ts +37 -0
- package/app/api/pacs/test/route.ts +30 -0
- package/app/api/pacs/wado/render/route.ts +51 -0
- package/app/api/patients/[id]/reports/route.ts +18 -0
- package/app/api/patients/[id]/route.ts +43 -0
- package/app/api/patients/merge/route.ts +57 -0
- package/app/api/patients/route.ts +67 -0
- package/app/api/patients/search/route.ts +25 -0
- package/app/api/reports/[id]/route.ts +84 -0
- package/app/api/reports/[id]/status/route.ts +87 -0
- package/app/api/reports/clear/route.ts +16 -0
- package/app/api/reports/route.ts +112 -0
- package/app/api/segmentation-config/route.ts +238 -0
- package/app/api/settings/route.ts +245 -0
- package/app/api/settings/test-supabase/route.ts +103 -0
- package/app/api/upload/route.ts +48 -0
- package/app/copilot/page.tsx +30 -0
- package/app/globals.css +141 -0
- package/app/history/page.tsx +242 -0
- package/app/icon.svg +3 -0
- package/app/layout.tsx +47 -0
- package/app/login/page.tsx +175 -0
- package/app/pacs/page.tsx +78 -0
- package/app/page.tsx +125 -0
- package/app/patients/[id]/page.tsx +315 -0
- package/app/patients/page.tsx +110 -0
- package/app/profile/page.tsx +208 -0
- package/app/reports/page.tsx +432 -0
- package/app/settings/page.tsx +454 -0
- package/app/setup/page.tsx +199 -0
- package/components/admin/AuditLogTable.tsx +293 -0
- package/components/copilot/ActivityIndicator.tsx +215 -0
- package/components/copilot/ChatHistoryPanel.tsx +140 -0
- package/components/copilot/ChatMessage.tsx +251 -0
- package/components/copilot/ClickableReference.tsx +40 -0
- package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
- package/components/copilot/CopilotPanel.tsx +311 -0
- package/components/copilot/FindingsList.tsx +75 -0
- package/components/copilot/ViewerPanel.tsx +460 -0
- package/components/copilot/WorkspaceLayout.tsx +398 -0
- package/components/dashboard/AIConfigPanel.tsx +339 -0
- package/components/dashboard/AppearancePanel.tsx +491 -0
- package/components/dashboard/ApprovalModal.tsx +163 -0
- package/components/dashboard/CollaborationPanel.tsx +134 -0
- package/components/dashboard/CopilotConfigPanel.tsx +337 -0
- package/components/dashboard/DicomViewer.tsx +645 -0
- package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
- package/components/dashboard/FullReportOverlay.tsx +269 -0
- package/components/dashboard/ImageViewer.tsx +541 -0
- package/components/dashboard/PatientForm.tsx +597 -0
- package/components/dashboard/RejectionModal.tsx +74 -0
- package/components/dashboard/ReportEditor.tsx +160 -0
- package/components/dashboard/ReportTemplates.tsx +729 -0
- package/components/dashboard/ReportView.tsx +539 -0
- package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
- package/components/dashboard/StudyPlaceholder.tsx +17 -0
- package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
- package/components/dashboard/UserManagementPanel.tsx +272 -0
- package/components/layout/ClientLayout.tsx +39 -0
- package/components/layout/Header.tsx +20 -0
- package/components/layout/Sidebar.tsx +119 -0
- package/components/pacs/PacsImageViewerModal.tsx +121 -0
- package/components/pacs/PacsSearchFilters.tsx +117 -0
- package/components/pacs/PacsSeriesViewer.tsx +190 -0
- package/components/pacs/PacsStudyTable.tsx +113 -0
- package/components/patients/patient-card.tsx +117 -0
- package/components/patients/patient-header.tsx +122 -0
- package/components/patients/patient-search.tsx +137 -0
- package/components/patients/patient-timeline.tsx +153 -0
- package/components/settings/ComplianceSettingsPanel.tsx +278 -0
- package/components/settings/SecurityPanel.tsx +418 -0
- package/components/ui/badge.tsx +19 -0
- package/components/ui/basic.tsx +156 -0
- package/db/index.ts +350 -0
- package/db/migrations/0000_odd_quasimodo.sql +117 -0
- package/db/migrations/meta/0000_snapshot.json +778 -0
- package/db/migrations/meta/_journal.json +13 -0
- package/db/schema.ts +239 -0
- package/drizzle.config.ts +10 -0
- package/lib/api.ts +689 -0
- package/lib/auth.ts +22 -0
- package/lib/copilot/action-executor.ts +94 -0
- package/lib/copilot/action-types.ts +72 -0
- package/lib/copilot/coordinate-mapper.ts +84 -0
- package/lib/dicomImageExtractor.ts +103 -0
- package/lib/dicomMetadataParser.ts +111 -0
- package/lib/fhir/client.ts +25 -0
- package/lib/fhir/constants.ts +21 -0
- package/lib/fhir/diagnostic-report.ts +88 -0
- package/lib/fhir/helpers.ts +73 -0
- package/lib/fhir/imaging-study.ts +49 -0
- package/lib/fhir/patient.ts +55 -0
- package/lib/fhir/service-request.ts +85 -0
- package/lib/fhir.ts +6 -0
- package/lib/pacs/dicom-utils.ts +72 -0
- package/lib/pacs/dicomweb.ts +72 -0
- package/lib/pacs/server-utils.ts +37 -0
- package/lib/patients.ts +25 -0
- package/lib/pdfHelper.ts +119 -0
- package/lib/reportHtmlGenerator.ts +581 -0
- package/lib/security/audit.ts +180 -0
- package/lib/security/authz.ts +246 -0
- package/lib/security/phi-redaction.ts +156 -0
- package/lib/security/rate-limit.ts +106 -0
- package/lib/security/secrets.ts +179 -0
- package/lib/supabase.ts +72 -0
- package/lib/utils.ts +6 -0
- package/next.config.ts +35 -0
- package/package.json +76 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/logo.svg +8 -0
- package/public/next.svg +1 -0
- package/public/omnirad-favicon.svg +8 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
- package/types/copilot-viewer.ts +155 -0
- package/types/copilot.ts +105 -0
- package/types/fhir.ts +21 -0
- package/types/html2pdf.d.ts +20 -0
- package/types/index.ts +139 -0
- package/types/pacs.ts +41 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/basic"
|
|
5
|
+
import { CheckCircle, Save, Building2, ImageIcon, Upload, Trash2 } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
interface AppearancePanelProps {
|
|
8
|
+
onThemeChange?: (theme: string) => void;
|
|
9
|
+
onTemplateChange?: (template: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function AppearancePanel({ onThemeChange, onTemplateChange }: AppearancePanelProps) {
|
|
13
|
+
const [theme, setTheme] = React.useState("dark");
|
|
14
|
+
const [selectedTemplate, setSelectedTemplate] = React.useState("standard");
|
|
15
|
+
const [activeTemplate, setActiveTemplate] = React.useState("standard");
|
|
16
|
+
const [hospitalName, setHospitalName] = React.useState("");
|
|
17
|
+
const [logo, setLogo] = React.useState("");
|
|
18
|
+
// Track saved branding values for dirty detection
|
|
19
|
+
const [savedHospitalName, setSavedHospitalName] = React.useState("");
|
|
20
|
+
const [savedLogo, setSavedLogo] = React.useState("");
|
|
21
|
+
const [brandingSaveStatus, setBrandingSaveStatus] = React.useState<'idle' | 'saving' | 'saved'>('idle');
|
|
22
|
+
const [isUploading, setIsUploading] = React.useState(false);
|
|
23
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
24
|
+
|
|
25
|
+
const isBrandingDirty = hospitalName !== savedHospitalName || logo !== savedLogo;
|
|
26
|
+
|
|
27
|
+
// Load saved settings from SQLite
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
fetch('/api/settings?type=appearance')
|
|
30
|
+
.then(res => res.json())
|
|
31
|
+
.then(data => {
|
|
32
|
+
setTheme(data.theme || "dark");
|
|
33
|
+
const loadedTemplate = data.template || "standard";
|
|
34
|
+
setSelectedTemplate(loadedTemplate);
|
|
35
|
+
setActiveTemplate(loadedTemplate);
|
|
36
|
+
const loadedName = data.hospitalName || "";
|
|
37
|
+
const loadedLogo = data.logo || "";
|
|
38
|
+
setHospitalName(loadedName);
|
|
39
|
+
setLogo(loadedLogo);
|
|
40
|
+
setSavedHospitalName(loadedName);
|
|
41
|
+
setSavedLogo(loadedLogo);
|
|
42
|
+
})
|
|
43
|
+
.catch(e => console.error("Error loading appearance:", e));
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const saveSettings = (settings: any) => {
|
|
47
|
+
fetch('/api/settings', {
|
|
48
|
+
method: 'PUT',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ type: 'appearance', data: settings }),
|
|
51
|
+
}).catch(e => console.error("Error saving appearance:", e));
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const handleThemeChange = (newTheme: string) => {
|
|
55
|
+
setTheme(newTheme);
|
|
56
|
+
saveSettings({ theme: newTheme, template: activeTemplate, hospitalName, logo });
|
|
57
|
+
onThemeChange?.(newTheme);
|
|
58
|
+
|
|
59
|
+
if (newTheme === "light") {
|
|
60
|
+
document.documentElement.classList.remove("dark");
|
|
61
|
+
} else {
|
|
62
|
+
document.documentElement.classList.add("dark");
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handleTemplateSelect = (newTemplate: string) => {
|
|
67
|
+
setSelectedTemplate(newTemplate);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleActivateTemplate = () => {
|
|
71
|
+
setActiveTemplate(selectedTemplate);
|
|
72
|
+
saveSettings({ theme, template: selectedTemplate, hospitalName, logo });
|
|
73
|
+
onTemplateChange?.(selectedTemplate);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const handleBrandingFieldChange = (field: "hospitalName" | "logo", value: string) => {
|
|
77
|
+
if (field === "hospitalName") setHospitalName(value);
|
|
78
|
+
if (field === "logo") setLogo(value);
|
|
79
|
+
// Reset save status when user makes changes
|
|
80
|
+
if (brandingSaveStatus === 'saved') setBrandingSaveStatus('idle');
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleSaveBranding = async () => {
|
|
84
|
+
setBrandingSaveStatus('saving');
|
|
85
|
+
try {
|
|
86
|
+
await fetch('/api/settings', {
|
|
87
|
+
method: 'PUT',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
type: 'appearance',
|
|
91
|
+
data: { theme, template: activeTemplate, hospitalName, logo },
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
setSavedHospitalName(hospitalName);
|
|
95
|
+
setSavedLogo(logo);
|
|
96
|
+
setBrandingSaveStatus('saved');
|
|
97
|
+
// Reset the "saved" badge after 3 seconds
|
|
98
|
+
setTimeout(() => setBrandingSaveStatus('idle'), 3000);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.error("Error saving branding:", e);
|
|
101
|
+
setBrandingSaveStatus('idle');
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleLogoUpload = async (file: File) => {
|
|
106
|
+
if (!file.type.startsWith('image/')) {
|
|
107
|
+
alert('Please select an image file (PNG, JPG, SVG, etc.)');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (file.size > 5 * 1024 * 1024) {
|
|
111
|
+
alert('File too large. Maximum size is 5MB.');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
setIsUploading(true);
|
|
115
|
+
try {
|
|
116
|
+
const formData = new FormData();
|
|
117
|
+
formData.append('file', file);
|
|
118
|
+
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
|
119
|
+
const data = await res.json();
|
|
120
|
+
if (data.success && data.url) {
|
|
121
|
+
setLogo(data.url);
|
|
122
|
+
if (brandingSaveStatus === 'saved') setBrandingSaveStatus('idle');
|
|
123
|
+
} else {
|
|
124
|
+
alert(data.error || 'Upload failed');
|
|
125
|
+
}
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.error('Logo upload error:', e);
|
|
128
|
+
alert('Upload failed. Please try again.');
|
|
129
|
+
} finally {
|
|
130
|
+
setIsUploading(false);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const handleRemoveLogo = () => {
|
|
135
|
+
setLogo('');
|
|
136
|
+
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
137
|
+
if (brandingSaveStatus === 'saved') setBrandingSaveStatus('idle');
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<Card className="bg-bg-surface border-border-primary">
|
|
142
|
+
<CardHeader>
|
|
143
|
+
<CardTitle className="text-text-heading">Appearance</CardTitle>
|
|
144
|
+
<p className="text-sm text-text-secondary">Customize the look and feel of OmniRad</p>
|
|
145
|
+
</CardHeader>
|
|
146
|
+
<CardContent className="space-y-6">
|
|
147
|
+
{/* Theme Selector */}
|
|
148
|
+
<div className="space-y-2">
|
|
149
|
+
<label className="text-sm font-medium text-text-primary">Theme</label>
|
|
150
|
+
<div className="grid grid-cols-2 gap-3">
|
|
151
|
+
<button
|
|
152
|
+
onClick={() => handleThemeChange("dark")}
|
|
153
|
+
className={`p-4 rounded-lg border-2 transition-all ${theme === "dark"
|
|
154
|
+
? "border-primary-main bg-primary-main/10"
|
|
155
|
+
: "border-border-primary bg-bg-panel hover:border-primary-main/50"
|
|
156
|
+
}`}
|
|
157
|
+
>
|
|
158
|
+
<div className="flex items-center gap-2">
|
|
159
|
+
<div className="w-6 h-6 rounded bg-gray-900 border border-gray-700"></div>
|
|
160
|
+
<span className="text-sm font-medium text-text-primary">Dark Mode</span>
|
|
161
|
+
</div>
|
|
162
|
+
</button>
|
|
163
|
+
<button
|
|
164
|
+
onClick={() => handleThemeChange("light")}
|
|
165
|
+
className={`p-4 rounded-lg border-2 transition-all ${theme === "light"
|
|
166
|
+
? "border-primary-main bg-primary-main/10"
|
|
167
|
+
: "border-border-primary bg-bg-panel hover:border-primary-main/50"
|
|
168
|
+
}`}
|
|
169
|
+
>
|
|
170
|
+
<div className="flex items-center gap-2">
|
|
171
|
+
<div className="w-6 h-6 rounded bg-white border border-gray-300"></div>
|
|
172
|
+
<span className="text-sm font-medium text-text-primary">Light Mode</span>
|
|
173
|
+
</div>
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Report Template Selector */}
|
|
179
|
+
<div className="space-y-6 pt-6 border-t border-border-primary">
|
|
180
|
+
<div className="flex justify-between items-center">
|
|
181
|
+
<div>
|
|
182
|
+
<h4 className="text-base font-bold text-text-heading">Report Template Design</h4>
|
|
183
|
+
<p className="text-xs text-text-muted mt-1">Choose a visual style for your reports</p>
|
|
184
|
+
</div>
|
|
185
|
+
{selectedTemplate !== activeTemplate && (
|
|
186
|
+
<span className="text-xs font-medium text-orange-600 bg-orange-100 px-2 py-1 rounded-full animate-pulse">
|
|
187
|
+
Changes not saved
|
|
188
|
+
</span>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
193
|
+
{/* Standard Template Card */}
|
|
194
|
+
<div
|
|
195
|
+
className={`cursor-pointer group relative rounded-xl border-2 transition-all duration-200 overflow-hidden ${selectedTemplate === 'standard'
|
|
196
|
+
? 'border-primary-main ring-2 ring-primary-main/20 bg-primary-main/5'
|
|
197
|
+
: 'border-border-primary hover:border-primary-main/50 hover:bg-bg-surface'
|
|
198
|
+
}`}
|
|
199
|
+
onClick={() => handleTemplateSelect('standard')}
|
|
200
|
+
>
|
|
201
|
+
<div className="aspect-[3/4] w-full bg-white p-3 pointer-events-none relative shadow-inner">
|
|
202
|
+
<div className="h-full w-full border border-gray-100 flex flex-col p-2 scale-90 origin-top">
|
|
203
|
+
<div className="border-b-2 border-gray-800 pb-1 mb-2">
|
|
204
|
+
<div className="h-2 w-3/4 bg-gray-900 mb-1 rounded-sm"></div>
|
|
205
|
+
<div className="h-1 w-1/2 bg-gray-500 rounded-sm"></div>
|
|
206
|
+
</div>
|
|
207
|
+
<div className="flex gap-1 mb-2">
|
|
208
|
+
<div className="w-1/2 h-8 bg-gray-100 rounded-sm"></div>
|
|
209
|
+
<div className="w-1/2 h-8 bg-gray-100 rounded-sm"></div>
|
|
210
|
+
</div>
|
|
211
|
+
<div className="space-y-1">
|
|
212
|
+
<div className="h-1 w-1/3 bg-gray-800 mb-1 rounded-sm"></div>
|
|
213
|
+
<div className="h-0.5 w-full bg-gray-300 mb-2"></div>
|
|
214
|
+
<div className="space-y-1">
|
|
215
|
+
<div className="h-1 w-full bg-gray-200 rounded-sm"></div>
|
|
216
|
+
<div className="h-1 w-5/6 bg-gray-200 rounded-sm"></div>
|
|
217
|
+
<div className="h-1 w-full bg-gray-200 rounded-sm"></div>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
<div className="mt-auto pt-2 border-t-2 border-gray-800 flex justify-between">
|
|
221
|
+
<div className="h-2 w-8 bg-gray-300 rounded-sm"></div>
|
|
222
|
+
<div className="h-6 w-12 border border-dashed border-gray-300 rounded-sm"></div>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
{selectedTemplate === 'standard' && (
|
|
226
|
+
<div className="absolute inset-0 bg-primary-main/5 pointer-events-none" />
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
<div className="p-3 border-t border-border-primary bg-bg-panel/50">
|
|
230
|
+
<div className="flex justify-between items-center mb-1">
|
|
231
|
+
<p className={`font-bold text-sm ${selectedTemplate === 'standard' ? 'text-primary-main' : 'text-text-primary'}`}>Standard</p>
|
|
232
|
+
{activeTemplate === 'standard' && <CheckCircle size={14} className="text-green-600" />}
|
|
233
|
+
</div>
|
|
234
|
+
<p className="text-[10px] text-text-muted leading-tight">Classic medical serif design with gray visual blocks. Professional & Authoritative.</p>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
{/* Modern Template Card */}
|
|
239
|
+
<div
|
|
240
|
+
className={`cursor-pointer group relative rounded-xl border-2 transition-all duration-200 overflow-hidden ${selectedTemplate === 'modern'
|
|
241
|
+
? 'border-primary-main ring-2 ring-primary-main/20 bg-primary-main/5'
|
|
242
|
+
: 'border-border-primary hover:border-primary-main/50 hover:bg-bg-surface'
|
|
243
|
+
}`}
|
|
244
|
+
onClick={() => handleTemplateSelect('modern')}
|
|
245
|
+
>
|
|
246
|
+
<div className="aspect-[3/4] w-full bg-white p-3 pointer-events-none relative shadow-inner">
|
|
247
|
+
<div className="h-full w-full flex flex-col scale-90 origin-top bg-white">
|
|
248
|
+
<div className="bg-slate-900 h-8 w-full mb-2 p-1 flex flex-col justify-center">
|
|
249
|
+
<div className="h-1.5 w-1/2 bg-white/20 rounded-sm mb-0.5"></div>
|
|
250
|
+
<div className="h-1 w-1/3 bg-white/10 rounded-sm"></div>
|
|
251
|
+
</div>
|
|
252
|
+
<div className="h-6 w-full bg-slate-100 mb-2 rounded-sm border-b border-slate-200"></div>
|
|
253
|
+
<div className="space-y-2 px-1">
|
|
254
|
+
<div className="border border-slate-100 rounded bg-white p-1 shadow-sm">
|
|
255
|
+
<div className="h-1 w-1/4 bg-blue-500 mb-1 rounded-full"></div>
|
|
256
|
+
<div className="h-1 w-full bg-slate-100 rounded-sm"></div>
|
|
257
|
+
</div>
|
|
258
|
+
<div className="border border-red-50 rounded bg-red-50/20 p-1">
|
|
259
|
+
<div className="flex justify-between mb-1">
|
|
260
|
+
<div className="h-1 w-1/4 bg-slate-800 rounded-sm"></div>
|
|
261
|
+
<div className="h-1 w-6 bg-red-200 rounded-full"></div>
|
|
262
|
+
</div>
|
|
263
|
+
<div className="h-1 w-full bg-red-100/50 rounded-sm"></div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
{selectedTemplate === 'modern' && (
|
|
268
|
+
<div className="absolute inset-0 bg-primary-main/5 pointer-events-none" />
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
<div className="p-3 border-t border-border-primary bg-bg-panel/50">
|
|
272
|
+
<div className="flex justify-between items-center mb-1">
|
|
273
|
+
<p className={`font-bold text-sm ${selectedTemplate === 'modern' ? 'text-primary-main' : 'text-text-primary'}`}>Modern</p>
|
|
274
|
+
{activeTemplate === 'modern' && <CheckCircle size={14} className="text-green-600" />}
|
|
275
|
+
</div>
|
|
276
|
+
<p className="text-[10px] text-text-muted leading-tight">Digital-first sans-serif with bold blue header & cards. High contrast.</p>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
{/* Minimal Template Card */}
|
|
281
|
+
<div
|
|
282
|
+
className={`cursor-pointer group relative rounded-xl border-2 transition-all duration-200 overflow-hidden ${selectedTemplate === 'minimal'
|
|
283
|
+
? 'border-primary-main ring-2 ring-primary-main/20 bg-primary-main/5'
|
|
284
|
+
: 'border-border-primary hover:border-primary-main/50 hover:bg-bg-surface'
|
|
285
|
+
}`}
|
|
286
|
+
onClick={() => handleTemplateSelect('minimal')}
|
|
287
|
+
>
|
|
288
|
+
<div className="aspect-[3/4] w-full bg-white p-3 pointer-events-none relative shadow-inner">
|
|
289
|
+
<div className="h-full w-full flex flex-col p-1 font-mono scale-90 origin-top">
|
|
290
|
+
<div className="flex justify-between border-b border-black pb-1 mb-1">
|
|
291
|
+
<div className="h-1.5 w-1/3 bg-black rounded-sm"></div>
|
|
292
|
+
<div className="h-1 w-1/4 bg-gray-400 rounded-sm"></div>
|
|
293
|
+
</div>
|
|
294
|
+
<div className="grid grid-cols-4 gap-1 border-b border-black pb-2 mb-2">
|
|
295
|
+
<div className="h-2 bg-gray-100"></div>
|
|
296
|
+
<div className="h-2 bg-gray-100"></div>
|
|
297
|
+
<div className="h-2 bg-gray-100"></div>
|
|
298
|
+
<div className="h-2 bg-gray-100"></div>
|
|
299
|
+
</div>
|
|
300
|
+
<div className="space-y-1">
|
|
301
|
+
<div className="flex gap-1 border-b border-gray-100 pb-0.5">
|
|
302
|
+
<div className="w-4 h-1 bg-gray-400"></div>
|
|
303
|
+
<div className="w-full h-1 bg-gray-200"></div>
|
|
304
|
+
</div>
|
|
305
|
+
<div className="flex gap-1 border-b border-gray-100 pb-0.5">
|
|
306
|
+
<div className="w-4 h-1 bg-gray-400"></div>
|
|
307
|
+
<div className="w-full h-1 bg-gray-200"></div>
|
|
308
|
+
</div>
|
|
309
|
+
<div className="flex gap-1 border-b border-gray-100 pb-0.5">
|
|
310
|
+
<div className="w-4 h-1 bg-gray-400"></div>
|
|
311
|
+
<div className="w-full h-1 bg-gray-200"></div>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
<div className="mt-auto border-t border-black pt-1 flex justify-between items-end">
|
|
315
|
+
<div className="h-1 w-10 bg-gray-300"></div>
|
|
316
|
+
<div className="flex flex-col items-end">
|
|
317
|
+
<div className="h-3 w-8 bg-gray-200 mb-0.5"></div>
|
|
318
|
+
<div className="h-1 w-12 bg-black"></div>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
{selectedTemplate === 'minimal' && (
|
|
323
|
+
<div className="absolute inset-0 bg-primary-main/5 pointer-events-none" />
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
<div className="p-3 border-t border-border-primary bg-bg-panel/50">
|
|
327
|
+
<div className="flex justify-between items-center mb-1">
|
|
328
|
+
<p className={`font-bold text-sm ${selectedTemplate === 'minimal' ? 'text-primary-main' : 'text-text-primary'}`}>Minimal</p>
|
|
329
|
+
{activeTemplate === 'minimal' && <CheckCircle size={14} className="text-green-600" />}
|
|
330
|
+
</div>
|
|
331
|
+
<p className="text-[10px] text-text-muted leading-tight">Compact A4 optimized layout. Dense data grid & minimal whitespace.</p>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
<div className="flex justify-end pt-2">
|
|
337
|
+
<button
|
|
338
|
+
onClick={handleActivateTemplate}
|
|
339
|
+
disabled={selectedTemplate === activeTemplate}
|
|
340
|
+
className={`px-6 py-2.5 rounded-lg font-bold text-sm shadow-sm transition-all transform active:scale-95 ${selectedTemplate === activeTemplate
|
|
341
|
+
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
342
|
+
: 'bg-primary-main text-white hover:bg-primary-dark shadow-md hover:shadow-lg'
|
|
343
|
+
}`}
|
|
344
|
+
>
|
|
345
|
+
{selectedTemplate === activeTemplate ? 'Template Active' : 'Activate Template'}
|
|
346
|
+
</button>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
{/* Hospital Branding */}
|
|
351
|
+
<div className="space-y-5 pt-6 border-t border-border-primary">
|
|
352
|
+
<div className="flex justify-between items-center">
|
|
353
|
+
<div className="flex items-center gap-2">
|
|
354
|
+
<Building2 size={18} className="text-text-heading" />
|
|
355
|
+
<div>
|
|
356
|
+
<h4 className="text-base font-bold text-text-heading">Hospital Branding</h4>
|
|
357
|
+
<p className="text-xs text-text-muted mt-0.5">Customize your hospital identity on reports</p>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
{isBrandingDirty && (
|
|
361
|
+
<span className="text-xs font-medium text-orange-600 bg-orange-100 px-2 py-1 rounded-full animate-pulse">
|
|
362
|
+
Unsaved changes
|
|
363
|
+
</span>
|
|
364
|
+
)}
|
|
365
|
+
{brandingSaveStatus === 'saved' && !isBrandingDirty && (
|
|
366
|
+
<span className="text-xs font-medium text-green-600 bg-green-100 px-2 py-1 rounded-full flex items-center gap-1">
|
|
367
|
+
<CheckCircle size={12} /> Saved
|
|
368
|
+
</span>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<div className="space-y-2">
|
|
373
|
+
<label className="text-sm font-medium text-text-primary">Hospital Name</label>
|
|
374
|
+
<input
|
|
375
|
+
type="text"
|
|
376
|
+
value={hospitalName}
|
|
377
|
+
onChange={(e) => handleBrandingFieldChange("hospitalName", e.target.value)}
|
|
378
|
+
placeholder="General Hospital"
|
|
379
|
+
className="w-full px-4 py-2.5 bg-bg-panel border border-border-primary rounded-lg text-text-primary placeholder-text-muted focus:outline-none focus:border-primary-main focus:ring-1 focus:ring-primary-main/30 transition-all"
|
|
380
|
+
/>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
<div className="space-y-3">
|
|
384
|
+
<label className="text-sm font-medium text-text-primary">Hospital Logo</label>
|
|
385
|
+
|
|
386
|
+
{/* Hidden file input */}
|
|
387
|
+
<input
|
|
388
|
+
ref={fileInputRef}
|
|
389
|
+
type="file"
|
|
390
|
+
accept="image/png,image/jpeg,image/jpg,image/gif,image/svg+xml,image/webp"
|
|
391
|
+
className="hidden"
|
|
392
|
+
onChange={(e) => {
|
|
393
|
+
const file = e.target.files?.[0];
|
|
394
|
+
if (file) handleLogoUpload(file);
|
|
395
|
+
}}
|
|
396
|
+
/>
|
|
397
|
+
|
|
398
|
+
{logo ? (
|
|
399
|
+
/* Logo Preview */
|
|
400
|
+
<div className="relative group rounded-lg border border-border-primary bg-bg-panel overflow-hidden">
|
|
401
|
+
<div className="p-4 flex items-center gap-4">
|
|
402
|
+
<div className="w-20 h-20 rounded-lg bg-white border border-gray-200 flex items-center justify-center p-2 shrink-0">
|
|
403
|
+
<img src={logo} alt="Hospital Logo" className="max-w-full max-h-full object-contain" />
|
|
404
|
+
</div>
|
|
405
|
+
<div className="flex-1 min-w-0">
|
|
406
|
+
<p className="text-sm font-medium text-text-primary truncate">Logo configured</p>
|
|
407
|
+
<p className="text-xs text-text-muted truncate mt-0.5">{logo}</p>
|
|
408
|
+
<div className="flex items-center gap-2 mt-2">
|
|
409
|
+
<button
|
|
410
|
+
onClick={() => fileInputRef.current?.click()}
|
|
411
|
+
className="text-xs px-3 py-1 rounded-md bg-bg-surface border border-border-primary text-text-primary hover:bg-primary-main/10 hover:border-primary-main/50 transition-all"
|
|
412
|
+
>
|
|
413
|
+
Change
|
|
414
|
+
</button>
|
|
415
|
+
<button
|
|
416
|
+
onClick={handleRemoveLogo}
|
|
417
|
+
className="text-xs px-3 py-1 rounded-md bg-red-50 border border-red-200 text-red-600 hover:bg-red-100 transition-all flex items-center gap-1"
|
|
418
|
+
>
|
|
419
|
+
<Trash2 size={12} /> Remove
|
|
420
|
+
</button>
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
) : (
|
|
426
|
+
/* Upload Drop Zone */
|
|
427
|
+
<div
|
|
428
|
+
onClick={() => !isUploading && fileInputRef.current?.click()}
|
|
429
|
+
onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('border-primary-main', 'bg-primary-main/5'); }}
|
|
430
|
+
onDragLeave={(e) => { e.preventDefault(); e.currentTarget.classList.remove('border-primary-main', 'bg-primary-main/5'); }}
|
|
431
|
+
onDrop={(e) => {
|
|
432
|
+
e.preventDefault();
|
|
433
|
+
e.currentTarget.classList.remove('border-primary-main', 'bg-primary-main/5');
|
|
434
|
+
const file = e.dataTransfer.files?.[0];
|
|
435
|
+
if (file) handleLogoUpload(file);
|
|
436
|
+
}}
|
|
437
|
+
className={`relative cursor-pointer rounded-lg border-2 border-dashed border-border-primary bg-bg-panel/50 p-6 text-center transition-all hover:border-primary-main hover:bg-primary-main/5 ${isUploading ? 'opacity-60 pointer-events-none' : ''}`}
|
|
438
|
+
>
|
|
439
|
+
{isUploading ? (
|
|
440
|
+
<div className="flex flex-col items-center gap-2">
|
|
441
|
+
<div className="w-8 h-8 border-2 border-primary-main border-t-transparent rounded-full animate-spin" />
|
|
442
|
+
<p className="text-sm text-text-primary font-medium">Uploading...</p>
|
|
443
|
+
</div>
|
|
444
|
+
) : (
|
|
445
|
+
<div className="flex flex-col items-center gap-2">
|
|
446
|
+
<div className="w-12 h-12 rounded-full bg-primary-main/10 flex items-center justify-center">
|
|
447
|
+
<Upload size={20} className="text-primary-main" />
|
|
448
|
+
</div>
|
|
449
|
+
<div>
|
|
450
|
+
<p className="text-sm font-medium text-text-primary">Click to upload or drag & drop</p>
|
|
451
|
+
<p className="text-xs text-text-muted mt-0.5">PNG, JPG, SVG, or WebP (max 5MB)</p>
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
)}
|
|
457
|
+
|
|
458
|
+
{/* Collapsible URL input as fallback */}
|
|
459
|
+
<details className="group">
|
|
460
|
+
<summary className="text-xs text-text-muted cursor-pointer hover:text-primary-main transition-colors select-none">
|
|
461
|
+
Or enter a logo URL manually
|
|
462
|
+
</summary>
|
|
463
|
+
<input
|
|
464
|
+
type="text"
|
|
465
|
+
value={logo}
|
|
466
|
+
onChange={(e) => handleBrandingFieldChange("logo", e.target.value)}
|
|
467
|
+
placeholder="https://example.com/logo.png"
|
|
468
|
+
className="mt-2 w-full px-4 py-2 bg-bg-panel border border-border-primary rounded-lg text-text-primary text-sm placeholder-text-muted focus:outline-none focus:border-primary-main focus:ring-1 focus:ring-primary-main/30 transition-all"
|
|
469
|
+
/>
|
|
470
|
+
</details>
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
<div className="flex justify-end pt-2">
|
|
474
|
+
<button
|
|
475
|
+
onClick={handleSaveBranding}
|
|
476
|
+
disabled={!isBrandingDirty || brandingSaveStatus === 'saving'}
|
|
477
|
+
className={`px-6 py-2.5 rounded-lg font-bold text-sm shadow-sm transition-all transform active:scale-95 flex items-center gap-2 ${
|
|
478
|
+
!isBrandingDirty || brandingSaveStatus === 'saving'
|
|
479
|
+
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
480
|
+
: 'bg-primary-main text-white hover:bg-primary-dark shadow-md hover:shadow-lg'
|
|
481
|
+
}`}
|
|
482
|
+
>
|
|
483
|
+
<Save size={14} />
|
|
484
|
+
{brandingSaveStatus === 'saving' ? 'Saving...' : isBrandingDirty ? 'Save & Activate' : 'Branding Saved'}
|
|
485
|
+
</button>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
</CardContent>
|
|
489
|
+
</Card>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Button } from "@/components/ui/basic";
|
|
3
|
+
import { X, CheckCircle, Trash2, PenTool } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
interface ApprovalModalProps {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
onConfirm: (signature: string, comments: string) => void;
|
|
9
|
+
currentUser: { name: string };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ApprovalModal({ isOpen, onClose, onConfirm, currentUser }: ApprovalModalProps) {
|
|
13
|
+
const [comments, setComments] = React.useState("");
|
|
14
|
+
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
|
15
|
+
const [isDrawing, setIsDrawing] = React.useState(false);
|
|
16
|
+
const [hasSignature, setHasSignature] = React.useState(false);
|
|
17
|
+
|
|
18
|
+
// Canvas drawing logic
|
|
19
|
+
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
|
|
20
|
+
const canvas = canvasRef.current;
|
|
21
|
+
if (!canvas) return;
|
|
22
|
+
const ctx = canvas.getContext("2d");
|
|
23
|
+
if (!ctx) return;
|
|
24
|
+
|
|
25
|
+
setIsDrawing(true);
|
|
26
|
+
const { offsetX, offsetY } = getCoordinates(e, canvas);
|
|
27
|
+
ctx.beginPath();
|
|
28
|
+
ctx.moveTo(offsetX, offsetY);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const draw = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
|
|
32
|
+
if (!isDrawing) return;
|
|
33
|
+
const canvas = canvasRef.current;
|
|
34
|
+
if (!canvas) return;
|
|
35
|
+
const ctx = canvas.getContext("2d");
|
|
36
|
+
if (!ctx) return;
|
|
37
|
+
|
|
38
|
+
const { offsetX, offsetY } = getCoordinates(e, canvas);
|
|
39
|
+
ctx.lineTo(offsetX, offsetY);
|
|
40
|
+
ctx.stroke();
|
|
41
|
+
setHasSignature(true);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const stopDrawing = () => {
|
|
45
|
+
setIsDrawing(false);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const getCoordinates = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>, canvas: HTMLCanvasElement) => {
|
|
49
|
+
let offsetX, offsetY;
|
|
50
|
+
if ('touches' in e) {
|
|
51
|
+
const rect = canvas.getBoundingClientRect();
|
|
52
|
+
offsetX = e.touches[0].clientX - rect.left;
|
|
53
|
+
offsetY = e.touches[0].clientY - rect.top;
|
|
54
|
+
} else {
|
|
55
|
+
offsetX = e.nativeEvent.offsetX;
|
|
56
|
+
offsetY = e.nativeEvent.offsetY;
|
|
57
|
+
}
|
|
58
|
+
return { offsetX, offsetY };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const clearCanvas = () => {
|
|
62
|
+
const canvas = canvasRef.current;
|
|
63
|
+
if (!canvas) return;
|
|
64
|
+
const ctx = canvas.getContext("2d");
|
|
65
|
+
if (!ctx) return;
|
|
66
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
67
|
+
setHasSignature(false);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleConfirm = () => {
|
|
71
|
+
const canvas = canvasRef.current;
|
|
72
|
+
if (!canvas) return;
|
|
73
|
+
const signature = canvas.toDataURL(); // Get base64 signature
|
|
74
|
+
onConfirm(signature, comments);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (!isOpen) return null;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm animate-in fade-in zoom-in duration-200">
|
|
81
|
+
<div className="bg-bg-surface w-[500px] rounded-lg shadow-xl border border-border-primary overflow-hidden">
|
|
82
|
+
<div className="flex items-center justify-between p-4 border-b border-border-primary bg-green-50">
|
|
83
|
+
<div className="flex items-center gap-2 text-green-700">
|
|
84
|
+
<CheckCircle size={20} />
|
|
85
|
+
<h2 className="text-lg font-bold">Approve Report</h2>
|
|
86
|
+
</div>
|
|
87
|
+
<button onClick={onClose} className="text-text-muted hover:text-text-primary">
|
|
88
|
+
<X size={20} />
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div className="p-6 space-y-6">
|
|
93
|
+
<div className="bg-blue-50 p-3 rounded-md border border-blue-100 text-sm text-blue-800">
|
|
94
|
+
You are approving this report as <strong>{currentUser.name}</strong> on {new Date().toLocaleDateString()}.
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div>
|
|
98
|
+
<label className="block text-sm font-medium text-text-primary mb-1">
|
|
99
|
+
Comments (Optional)
|
|
100
|
+
</label>
|
|
101
|
+
<textarea
|
|
102
|
+
rows={2}
|
|
103
|
+
placeholder="Any final notes..."
|
|
104
|
+
className="w-full px-3 py-2 border border-border-primary rounded-md focus:outline-none focus:ring-2 focus:ring-green-500/20"
|
|
105
|
+
value={comments}
|
|
106
|
+
onChange={(e) => setComments(e.target.value)}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div>
|
|
111
|
+
<div className="flex justify-between items-center mb-1">
|
|
112
|
+
<label className="block text-sm font-medium text-text-primary">
|
|
113
|
+
Signature <span className="text-red-500">*</span>
|
|
114
|
+
</label>
|
|
115
|
+
<button
|
|
116
|
+
onClick={clearCanvas}
|
|
117
|
+
className="text-xs text-red-600 hover:text-red-800 flex items-center gap-1"
|
|
118
|
+
disabled={!hasSignature}
|
|
119
|
+
>
|
|
120
|
+
<Trash2 size={12} /> Clear
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
<div className="border border-border-primary rounded-md bg-white cursor-crosshair touch-none">
|
|
124
|
+
<canvas
|
|
125
|
+
ref={canvasRef}
|
|
126
|
+
width={450}
|
|
127
|
+
height={150}
|
|
128
|
+
onMouseDown={startDrawing}
|
|
129
|
+
onMouseMove={draw}
|
|
130
|
+
onMouseUp={stopDrawing}
|
|
131
|
+
onMouseLeave={stopDrawing}
|
|
132
|
+
onTouchStart={startDrawing}
|
|
133
|
+
onTouchMove={draw}
|
|
134
|
+
onTouchEnd={stopDrawing}
|
|
135
|
+
className="w-full h-[150px]"
|
|
136
|
+
/>
|
|
137
|
+
{!hasSignature && (
|
|
138
|
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none text-gray-300 opacity-0">
|
|
139
|
+
<PenTool size={24} />
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
<p className="text-xs text-text-muted mt-1">Sign above using your mouse or touch screen.</p>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div className="flex justify-end gap-3 p-4 bg-bg-panel border-t border-border-primary">
|
|
148
|
+
<Button variant="outline" onClick={onClose}>
|
|
149
|
+
Cancel
|
|
150
|
+
</Button>
|
|
151
|
+
<Button
|
|
152
|
+
variant="success"
|
|
153
|
+
onClick={handleConfirm}
|
|
154
|
+
disabled={!hasSignature}
|
|
155
|
+
className="bg-green-600 hover:bg-green-700 text-white"
|
|
156
|
+
>
|
|
157
|
+
Confirm Approval
|
|
158
|
+
</Button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|