Add patient management, deployment scripts, and Docker fixes

This commit is contained in:
2026-01-30 01:51:33 -08:00
parent 745f9f827f
commit d28d2f20c6
33 changed files with 7496 additions and 284 deletions

View File

@@ -16,8 +16,9 @@ RUN npm ci
# Copy source code
COPY . .
# Build the app (uses relative API URLs)
# Build the app (uses relative API URLs for all API calls)
ENV VITE_API_URL=""
ENV VITE_API_BASE=""
RUN npm run build
# Stage 2: Serve with nginx

View File

@@ -16,8 +16,24 @@ server {
# Increase max body size for file uploads
client_max_body_size 100M;
# CORS headers - allow all origins
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
# Proxy API requests to the API container
location /api/ {
# Handle preflight OPTIONS requests
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://api:3002/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -29,6 +45,11 @@ server {
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
# Add CORS headers to response
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
}
# Proxy file requests to the API container
@@ -39,6 +60,11 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
# Add CORS headers
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
}
# Serve static assets with caching

View File

@@ -8,6 +8,11 @@ import CaseDetailPage from "./pages/CaseDetail";
import PipelineCaseDetail from "./pages/PipelineCaseDetail";
import ShellEditorPage from "./pages/ShellEditorPage";
// Patient pages
import PatientList from "./pages/PatientList";
import PatientDetail from "./pages/PatientDetail";
import PatientForm from "./pages/PatientForm";
// Admin pages
import AdminDashboard from "./pages/admin/AdminDashboard";
import AdminUsers from "./pages/admin/AdminUsers";
@@ -110,6 +115,48 @@ function AppRoutes() {
}
/>
{/* Patient routes */}
<Route
path="/patients"
element={
<ProtectedRoute>
<AppShell>
<PatientList />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/patients/new"
element={
<ProtectedRoute>
<AppShell>
<PatientForm />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/patients/:patientId"
element={
<ProtectedRoute>
<AppShell>
<PatientDetail />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/patients/:patientId/edit"
element={
<ProtectedRoute>
<AppShell>
<PatientForm />
</AppShell>
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route
path="/admin"

View File

@@ -5,7 +5,7 @@
import { getAuthHeaders } from "../context/AuthContext";
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:3001/api";
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
async function adminFetch<T>(path: string, init?: RequestInit): Promise<T> {
const url = `${API_BASE}${path}`;
@@ -101,6 +101,8 @@ export type AdminCase = {
analysis_result: any;
landmarks_data: any;
body_scan_path: string | null;
is_archived?: boolean;
archived_at?: string | null;
created_by: number | null;
created_by_username: string | null;
created_at: string;
@@ -122,6 +124,8 @@ export async function listCasesAdmin(params?: {
offset?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
includeArchived?: boolean;
archivedOnly?: boolean;
}): Promise<ListCasesResponse> {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set("status", params.status);
@@ -131,11 +135,31 @@ export async function listCasesAdmin(params?: {
if (params?.offset) searchParams.set("offset", params.offset.toString());
if (params?.sortBy) searchParams.set("sortBy", params.sortBy);
if (params?.sortOrder) searchParams.set("sortOrder", params.sortOrder);
if (params?.includeArchived) searchParams.set("includeArchived", "true");
if (params?.archivedOnly) searchParams.set("archivedOnly", "true");
const query = searchParams.toString();
return adminFetch(`/admin/cases${query ? `?${query}` : ""}`);
}
/**
* Restore (unarchive) a case - admin only
*/
export async function restoreCase(caseId: string): Promise<{ caseId: string; archived: boolean; message: string }> {
return adminFetch(`/cases/${encodeURIComponent(caseId)}/unarchive`, {
method: "POST",
});
}
/**
* Restore (unarchive) a patient - admin only
*/
export async function restorePatient(patientId: number): Promise<{ patientId: number; archived: boolean; message: string }> {
return adminFetch(`/patients/${patientId}/unarchive`, {
method: "POST",
});
}
// ============================================
// ANALYTICS
// ============================================
@@ -234,3 +258,145 @@ export async function getAuditLog(params?: {
const query = searchParams.toString();
return adminFetch(`/admin/audit-log${query ? `?${query}` : ""}`);
}
// ============================================
// API REQUEST ACTIVITY LOG
// ============================================
export type FileUploadInfo = {
fieldname: string;
originalname: string;
mimetype: string;
size: number;
destination?: string;
filename?: string;
};
export type ResponseSummary = {
success?: boolean;
message?: string;
error?: string;
caseId?: string;
status?: string;
userId?: number;
username?: string;
tokenGenerated?: boolean;
rigoType?: string;
cobbAngles?: { PT?: number; MT?: number; TL?: number };
vertebraeDetected?: number;
bracesGenerated?: { regular: boolean; vase: boolean };
braceGenerated?: boolean;
braceVertices?: number;
filesGenerated?: string[];
outputUrl?: string;
landmarksCount?: number | string;
casesCount?: number;
usersCount?: number;
entriesCount?: number;
requestsCount?: number;
total?: number;
bodyScanUploaded?: boolean;
measurementsExtracted?: boolean;
errorCode?: number;
};
export type ApiRequestEntry = {
id: number;
user_id: number | null;
username: string | null;
method: string;
path: string;
route_pattern: string | null;
query_params: string | null;
request_params: string | null;
file_uploads: string | null;
status_code: number | null;
response_time_ms: number | null;
response_summary: string | null;
ip_address: string | null;
user_agent: string | null;
request_body_size: number | null;
response_body_size: number | null;
error_message: string | null;
created_at: string;
};
export type ApiRequestsResponse = {
requests: ApiRequestEntry[];
total: number;
limit: number;
offset: number;
};
export type ApiActivityStats = {
total: number;
byMethod: Record<string, number>;
byStatusCategory: Record<string, number>;
topEndpoints: {
method: string;
path: string;
count: number;
avg_response_time: number;
}[];
topUsers: {
user_id: number | null;
username: string | null;
count: number;
}[];
responseTime: {
avg: number;
min: number;
max: number;
};
requestsPerHour: {
hour: string;
count: number;
}[];
errorRate: number;
};
export async function getApiActivity(params?: {
userId?: number;
username?: string;
method?: string;
path?: string;
statusCode?: number;
statusCategory?: "2xx" | "3xx" | "4xx" | "5xx";
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
}): Promise<ApiRequestsResponse> {
const searchParams = new URLSearchParams();
if (params?.userId) searchParams.set("userId", params.userId.toString());
if (params?.username) searchParams.set("username", params.username);
if (params?.method) searchParams.set("method", params.method);
if (params?.path) searchParams.set("path", params.path);
if (params?.statusCode) searchParams.set("statusCode", params.statusCode.toString());
if (params?.statusCategory) searchParams.set("statusCategory", params.statusCategory);
if (params?.startDate) searchParams.set("startDate", params.startDate);
if (params?.endDate) searchParams.set("endDate", params.endDate);
if (params?.limit) searchParams.set("limit", params.limit.toString());
if (params?.offset) searchParams.set("offset", params.offset.toString());
const query = searchParams.toString();
return adminFetch(`/admin/activity${query ? `?${query}` : ""}`);
}
export async function getApiActivityStats(params?: {
startDate?: string;
endDate?: string;
}): Promise<{ stats: ApiActivityStats }> {
const searchParams = new URLSearchParams();
if (params?.startDate) searchParams.set("startDate", params.startDate);
if (params?.endDate) searchParams.set("endDate", params.endDate);
const query = searchParams.toString();
return adminFetch(`/admin/activity/stats${query ? `?${query}` : ""}`);
}
export async function cleanupApiActivityLogs(daysToKeep: number = 30): Promise<{ message: string; deletedCount: number }> {
return adminFetch(`/admin/activity/cleanup?daysToKeep=${daysToKeep}`, {
method: "DELETE",
});
}

View File

@@ -1,5 +1,18 @@
export type CasePatient = {
id: number;
firstName: string;
lastName: string;
fullName: string;
mrn?: string | null;
dateOfBirth?: string | null;
gender?: string | null;
};
export type CaseRecord = {
caseId: string;
patient_id?: number | null;
patient?: CasePatient | null;
visit_date?: string | null;
status: string;
current_step: string | null;
created_at: string;
@@ -356,7 +369,28 @@ export async function analyzeXray(
}
/**
* Delete a case and all associated files
* Archive a case (soft delete - preserves all files)
*/
export async function archiveCase(caseId: string): Promise<{ caseId: string; archived: boolean; message: string }> {
return await safeFetch<{ caseId: string; archived: boolean; message: string }>(
`/cases/${encodeURIComponent(caseId)}/archive`,
{ method: "POST" }
);
}
/**
* Unarchive a case (restore)
*/
export async function unarchiveCase(caseId: string): Promise<{ caseId: string; archived: boolean; message: string }> {
return await safeFetch<{ caseId: string; archived: boolean; message: string }>(
`/cases/${encodeURIComponent(caseId)}/unarchive`,
{ method: "POST" }
);
}
/**
* Delete a case - DEPRECATED: Use archiveCase instead
* This now archives instead of deleting
*/
export async function deleteCase(caseId: string): Promise<{ message: string }> {
return await safeFetch<{ message: string }>(

View File

@@ -0,0 +1,248 @@
/**
* Patient API Client
* API functions for patient management
*/
import { getAuthHeaders } from "../context/AuthContext";
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
async function patientFetch<T>(path: string, init?: RequestInit): Promise<T> {
const url = `${API_BASE}${path}`;
const response = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
...init?.headers,
},
});
const text = await response.text();
if (!response.ok) {
const error = text ? JSON.parse(text) : { message: "Request failed" };
throw new Error(error.message || `Request failed: ${response.status}`);
}
return text ? JSON.parse(text) : ({} as T);
}
// ============================================
// TYPES
// ============================================
export type Patient = {
id: number;
mrn: string | null;
first_name: string;
last_name: string;
date_of_birth: string | null;
gender: "male" | "female" | "other" | null;
email: string | null;
phone: string | null;
address: string | null;
diagnosis: string | null;
curve_type: string | null;
medical_history: string | null;
referring_physician: string | null;
insurance_info: string | null;
notes: string | null;
is_active: number;
created_by: number | null;
created_by_username: string | null;
created_at: string;
updated_at: string;
case_count?: number;
last_visit?: string | null;
};
export type PatientCase = {
case_id: string;
case_type: string;
status: string;
current_step: string | null;
visit_date: string | null;
notes: string | null;
analysis_result: any;
landmarks_data: any;
body_scan_path: string | null;
body_scan_url: string | null;
created_at: string;
updated_at: string;
};
export type PatientInput = {
mrn?: string;
firstName: string;
lastName: string;
dateOfBirth?: string;
gender?: "male" | "female" | "other";
email?: string;
phone?: string;
address?: string;
diagnosis?: string;
curveType?: string;
medicalHistory?: string;
referringPhysician?: string;
insuranceInfo?: string;
notes?: string;
};
export type PatientListResponse = {
patients: Patient[];
total: number;
limit: number;
offset: number;
};
export type PatientStats = {
total: number;
active: number;
inactive: number;
withCases: number;
byGender: Record<string, number>;
recentPatients: number;
};
// ============================================
// API FUNCTIONS
// ============================================
/**
* Create a new patient
*/
export async function createPatient(data: PatientInput): Promise<{ patient: Patient }> {
return patientFetch("/patients", {
method: "POST",
body: JSON.stringify(data),
});
}
/**
* List patients with optional filters
*/
export async function listPatients(params?: {
search?: string;
isActive?: boolean | "all";
limit?: number;
offset?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}): Promise<PatientListResponse> {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.set("search", params.search);
if (params?.isActive !== undefined) {
searchParams.set("isActive", params.isActive === "all" ? "all" : String(params.isActive));
}
if (params?.limit) searchParams.set("limit", params.limit.toString());
if (params?.offset) searchParams.set("offset", params.offset.toString());
if (params?.sortBy) searchParams.set("sortBy", params.sortBy);
if (params?.sortOrder) searchParams.set("sortOrder", params.sortOrder);
const query = searchParams.toString();
return patientFetch(`/patients${query ? `?${query}` : ""}`);
}
/**
* Get patient by ID with their cases
*/
export async function getPatient(patientId: number): Promise<{ patient: Patient; cases: PatientCase[] }> {
return patientFetch(`/patients/${patientId}`);
}
/**
* Update patient
*/
export async function updatePatient(
patientId: number,
data: Partial<PatientInput> & { isActive?: boolean }
): Promise<{ patient: Patient }> {
return patientFetch(`/patients/${patientId}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
/**
* Archive patient (soft delete - preserves all data)
*/
export async function archivePatient(patientId: number): Promise<{ patientId: number; archived: boolean; message: string }> {
return patientFetch(`/patients/${patientId}/archive`, {
method: "POST",
});
}
/**
* Unarchive patient (restore)
*/
export async function unarchivePatient(patientId: number): Promise<{ patientId: number; archived: boolean; message: string }> {
return patientFetch(`/patients/${patientId}/unarchive`, {
method: "POST",
});
}
/**
* Delete patient - DEPRECATED: Use archivePatient instead
* This now archives instead of deleting
*/
export async function deletePatient(patientId: number, hard = false): Promise<{ message: string }> {
return patientFetch(`/patients/${patientId}${hard ? "?hard=true" : ""}`, {
method: "DELETE",
});
}
/**
* Create a case for a patient
*/
export async function createPatientCase(
patientId: number,
data?: { notes?: string; visitDate?: string }
): Promise<{ caseId: string; patientId: number; status: string }> {
return patientFetch(`/patients/${patientId}/cases`, {
method: "POST",
body: JSON.stringify(data || {}),
});
}
/**
* Get patient statistics
*/
export async function getPatientStats(): Promise<{ stats: PatientStats }> {
return patientFetch("/patients-stats");
}
// ============================================
// UTILITY FUNCTIONS
// ============================================
/**
* Format patient name
*/
export function formatPatientName(patient: Patient | { first_name: string; last_name: string }): string {
return `${patient.first_name} ${patient.last_name}`;
}
/**
* Calculate patient age from date of birth
*/
export function calculateAge(dateOfBirth: string | null): number | null {
if (!dateOfBirth) return null;
const today = new Date();
const birth = new Date(dateOfBirth);
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age;
}
/**
* Format date for display
*/
export function formatDate(date: string | null): string {
if (!date) return "-";
return new Date(date).toLocaleDateString();
}

View File

@@ -34,6 +34,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const [shouldFadeIn, setShouldFadeIn] = useState(false);
const prevPathRef = useRef(location.pathname);
const isPatients = location.pathname === "/patients" || location.pathname.startsWith("/patients/");
const isCases = location.pathname === "/cases" || location.pathname.startsWith("/cases/");
const isEditShell = location.pathname.startsWith("/editor");
const isAdmin = location.pathname.startsWith("/admin");
@@ -72,6 +73,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div>
<nav className="bf-nav" aria-label="Primary navigation">
<NavItem label="Patients" active={isPatients} onClick={() => nav("/patients")} />
<NavItem label="Cases" active={isCases} onClick={() => nav("/cases")} />
<NavItem label="Editor" active={isEditShell} onClick={() => nav("/editor")} />
{userIsAdmin && (

View File

@@ -49,6 +49,50 @@
font-family: monospace;
}
.case-header-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.patient-name-header {
font-size: 20px;
font-weight: 700;
color: #fff;
margin: 0;
display: flex;
align-items: center;
gap: 12px;
}
.patient-mrn-badge {
font-size: 12px;
font-weight: 500;
color: #94a3b8;
background: rgba(148, 163, 184, 0.15);
padding: 4px 10px;
border-radius: 4px;
font-family: 'SF Mono', Monaco, Consolas, monospace;
}
.case-meta-row {
display: flex;
align-items: center;
gap: 12px;
}
.case-id-label {
font-size: 13px;
font-weight: 500;
color: #94a3b8;
font-family: 'SF Mono', Monaco, Consolas, monospace;
}
.visit-date-label {
font-size: 13px;
color: #64748b;
}
/* Status Badges */
.status-badge {
padding: 4px 10px;

View File

@@ -22,7 +22,7 @@ type AuthContextType = {
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const AUTH_STORAGE_KEY = "braceflow_auth";
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:3001/api";
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { fetchCases, createCaseAndUploadXray, getDownloadUrl, deleteCase } from "../api/braceflowApi";
import { fetchCases, getDownloadUrl, archiveCase } from "../api/braceflowApi";
import type { CaseRecord } from "../api/braceflowApi";
export default function Dashboard({ onView }: { onView?: (id: string) => void }) {
@@ -10,18 +10,12 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
// Upload state
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Thumbnail URLs for each case
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
// Dropdown menu state
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [archiving, setArchiving] = useState<string | null>(null);
async function load() {
setLoading(true);
@@ -57,19 +51,19 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
setThumbnails(prev => ({ ...prev, ...newThumbnails }));
}
// Handle delete case
async function handleDelete(caseId: string, e: React.MouseEvent) {
// Handle archive case (shown to user as "delete")
async function handleArchive(caseId: string, e: React.MouseEvent) {
e.stopPropagation();
if (!confirm(`Are you sure you want to delete case "${caseId}"?\n\nThis will permanently remove the case and all associated files.`)) {
if (!confirm(`Are you sure you want to delete case "${caseId}"?`)) {
return;
}
setDeleting(caseId);
setArchiving(caseId);
setOpenMenu(null);
try {
await deleteCase(caseId);
await archiveCase(caseId);
setCases(prev => prev.filter(c => c.caseId !== caseId));
setThumbnails(prev => {
const updated = { ...prev };
@@ -77,9 +71,9 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
return updated;
});
} catch (e: any) {
setErr(e?.message || "Failed to delete case");
setErr(e?.message || "Failed to archive case");
} finally {
setDeleting(null);
setArchiving(null);
}
}
@@ -120,59 +114,6 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
nav(`/cases/${encodeURIComponent(caseId)}/analysis`);
}
const handleFileUpload = useCallback(async (file: File) => {
if (!file.type.startsWith("image/")) {
setErr("Please upload an image file (JPEG, PNG, etc.)");
return;
}
setUploading(true);
setErr(null);
setUploadProgress("Creating case...");
try {
setUploadProgress("Uploading X-ray...");
const { caseId } = await createCaseAndUploadXray(file);
setUploadProgress("Complete!");
// Refresh the case list and navigate to the new case
await load();
viewCase(caseId);
} catch (e: any) {
setErr(e?.message || "Upload failed");
} finally {
setUploading(false);
setUploadProgress("");
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragActive(false);
const file = e.dataTransfer.files[0];
if (file) {
handleFileUpload(file);
}
}, [handleFileUpload]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragActive(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragActive(false);
}, []);
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileUpload(file);
}
}, [handleFileUpload]);
const hasCases = cases.length > 0;
return (
@@ -184,44 +125,12 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
</div>
<div className="bf-spacer" />
<div className="bf-toolbar">
<button className="btn secondary bf-btn-fixed" onClick={load} disabled={loading || uploading}>
<button className="btn secondary bf-btn-fixed" onClick={load} disabled={loading}>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
</div>
{/* Upload Area */}
<div
className={`bf-upload-zone ${dragActive ? "bf-upload-zone--active" : ""} ${uploading ? "bf-upload-zone--uploading" : ""}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => !uploading && fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileInputChange}
style={{ display: "none" }}
disabled={uploading}
/>
{uploading ? (
<div className="bf-upload-content">
<div className="bf-upload-spinner"></div>
<p className="bf-upload-text">{uploadProgress}</p>
</div>
) : (
<div className="bf-upload-content">
<div className="bf-upload-icon">+</div>
<p className="bf-upload-text">
<strong>Click to upload</strong> or drag and drop an X-ray image
</p>
<p className="bf-upload-hint">JPEG, PNG, WebP supported</p>
</div>
)}
</div>
{err && <div className="error" style={{ marginTop: "1rem" }}>{err}</div>}
{/* Cases List */}
@@ -231,20 +140,20 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
{loading ? (
<div className="muted">Loading cases...</div>
) : !hasCases ? (
<div className="bf-empty">No cases yet. Upload an X-ray above to create your first case.</div>
<div className="bf-empty">No cases yet. Go to <a href="/patients">Patients</a> to add a patient and create a case.</div>
) : (
<div className="bf-cases-list">
{cases.map((c) => {
const date = new Date(c.created_at);
const isValidDate = !isNaN(date.getTime());
const thumbUrl = thumbnails[c.caseId];
const isDeleting = deleting === c.caseId;
const isArchiving = archiving === c.caseId;
return (
<div
key={c.caseId}
className={`bf-case-row ${isDeleting ? "bf-case-row--deleting" : ""}`}
onClick={() => !isDeleting && viewCase(c.caseId)}
className={`bf-case-row ${isArchiving ? "bf-case-row--archiving" : ""}`}
onClick={() => !isArchiving && viewCase(c.caseId)}
>
{/* Thumbnail */}
<div className="bf-case-thumb">
@@ -257,6 +166,9 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
{/* Case Info */}
<div className="bf-case-row-info">
{c.patient && (
<span className="bf-case-row-patient">{c.patient.fullName}</span>
)}
<span className="bf-case-row-id">{c.caseId}</span>
{isValidDate && (
<span className="bf-case-row-date">
@@ -267,7 +179,7 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
{/* Menu Button */}
<div className="bf-case-menu-container">
{isDeleting ? (
{isArchiving ? (
<div className="bf-case-menu-spinner"></div>
) : (
<>
@@ -284,7 +196,7 @@ export default function Dashboard({ onView }: { onView?: (id: string) => void })
<div className="bf-case-dropdown">
<button
className="bf-case-dropdown-item bf-case-dropdown-item--danger"
onClick={(e) => handleDelete(c.caseId, e)}
onClick={(e) => handleArchive(c.caseId, e)}
>
Delete Case
</button>

View File

@@ -0,0 +1,338 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
getPatient,
createPatientCase,
type Patient,
type PatientCase,
formatPatientName,
calculateAge,
formatDate,
} from "../api/patientApi";
import { getDownloadUrl } from "../api/braceflowApi";
export default function PatientDetail() {
const navigate = useNavigate();
const { patientId } = useParams();
const [patient, setPatient] = useState<Patient | null>(null);
const [cases, setCases] = useState<PatientCase[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [creatingCase, setCreatingCase] = useState(false);
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
useEffect(() => {
loadPatient();
}, [patientId]);
async function loadPatient() {
try {
const data = await getPatient(parseInt(patientId!));
setPatient(data.patient);
setCases(data.cases);
// Load thumbnails for cases
loadThumbnails(data.cases);
} catch (err: any) {
setError(err.message || "Failed to load patient");
} finally {
setLoading(false);
}
}
async function loadThumbnails(caseList: PatientCase[]) {
const newThumbnails: Record<string, string> = {};
await Promise.all(
caseList.map(async (c) => {
try {
const result = await getDownloadUrl(c.case_id, "xray");
newThumbnails[c.case_id] = result.url;
} catch {
// No thumbnail available
}
})
);
setThumbnails(prev => ({ ...prev, ...newThumbnails }));
}
async function handleCreateCase() {
setCreatingCase(true);
setError(null);
try {
const result = await createPatientCase(parseInt(patientId!));
// Navigate to the new case
navigate(`/cases/${result.caseId}`);
} catch (err: any) {
setError(err.message || "Failed to create case");
setCreatingCase(false);
}
}
function getStatusColor(status: string): string {
switch (status) {
case "brace_generated":
return "bf-status-success";
case "analysis_complete":
case "landmarks_approved":
return "bf-status-info";
case "created":
case "landmarks_detected":
return "bf-status-warning";
case "failed":
case "brace_failed":
return "bf-status-error";
default:
return "bf-status-default";
}
}
function formatStatus(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
if (loading) {
return <div className="bf-loading">Loading patient...</div>;
}
if (!patient) {
return (
<div className="bf-error-state">
<h2>Patient not found</h2>
<button className="bf-btn bf-btn-primary" onClick={() => navigate("/patients")}>
Back to Patients
</button>
</div>
);
}
const age = calculateAge(patient.date_of_birth);
return (
<div className="bf-patient-detail-page">
{/* Header */}
<div className="bf-patient-detail-header">
<button className="bf-back-btn" onClick={() => navigate("/patients")}>
Back to Patients
</button>
<div className="bf-patient-detail-title">
<div className="bf-patient-avatar-large">
{patient.first_name[0]}
{patient.last_name[0]}
</div>
<div>
<h1>{formatPatientName(patient)}</h1>
<div className="bf-patient-meta">
{patient.mrn && <span className="bf-meta-item">MRN: {patient.mrn}</span>}
{age && <span className="bf-meta-item">{age} years old</span>}
{patient.gender && (
<span className="bf-meta-item bf-capitalize">{patient.gender}</span>
)}
{!patient.is_active && (
<span className="bf-badge bf-badge-inactive">Inactive</span>
)}
</div>
</div>
</div>
<div className="bf-patient-detail-actions">
<button
className="bf-btn bf-btn-outline"
onClick={() => navigate(`/patients/${patientId}/edit`)}
>
Edit Patient
</button>
</div>
</div>
{error && <div className="bf-error-message">{error}</div>}
<div className="bf-patient-detail-content">
{/* Patient Information */}
<div className="bf-patient-info-section">
<div className="bf-info-card">
<h3>Contact Information</h3>
<div className="bf-info-grid">
<div className="bf-info-item">
<span className="bf-info-label">Email</span>
<span className="bf-info-value">{patient.email || "-"}</span>
</div>
<div className="bf-info-item">
<span className="bf-info-label">Phone</span>
<span className="bf-info-value">{patient.phone || "-"}</span>
</div>
<div className="bf-info-item bf-info-item-full">
<span className="bf-info-label">Address</span>
<span className="bf-info-value">{patient.address || "-"}</span>
</div>
</div>
</div>
<div className="bf-info-card">
<h3>Medical Information</h3>
<div className="bf-info-grid">
<div className="bf-info-item">
<span className="bf-info-label">Diagnosis</span>
<span className="bf-info-value">{patient.diagnosis || "-"}</span>
</div>
<div className="bf-info-item">
<span className="bf-info-label">Curve Type</span>
<span className="bf-info-value">
{patient.curve_type ? (
<span className="bf-curve-badge">{patient.curve_type}</span>
) : (
"-"
)}
</span>
</div>
<div className="bf-info-item">
<span className="bf-info-label">Referring Physician</span>
<span className="bf-info-value">{patient.referring_physician || "-"}</span>
</div>
<div className="bf-info-item">
<span className="bf-info-label">Insurance</span>
<span className="bf-info-value">{patient.insurance_info || "-"}</span>
</div>
{patient.medical_history && (
<div className="bf-info-item bf-info-item-full">
<span className="bf-info-label">Medical History</span>
<span className="bf-info-value bf-info-text">
{patient.medical_history}
</span>
</div>
)}
{patient.notes && (
<div className="bf-info-item bf-info-item-full">
<span className="bf-info-label">Notes</span>
<span className="bf-info-value bf-info-text">{patient.notes}</span>
</div>
)}
</div>
</div>
</div>
{/* Cases Section */}
<div className="bf-patient-cases-section">
<div className="bf-section-header">
<h2>Cases ({cases.length})</h2>
<button
className="bf-btn bf-btn-small bf-btn-primary"
onClick={handleCreateCase}
disabled={creatingCase}
>
+ New Case
</button>
</div>
{cases.length > 0 ? (
<div className="bf-cases-list">
{cases.map((caseItem) => {
// Parse analysis result for additional info
let rigoType = null;
let cobbAngles = null;
try {
if (caseItem.analysis_result) {
const result =
typeof caseItem.analysis_result === "string"
? JSON.parse(caseItem.analysis_result)
: caseItem.analysis_result;
rigoType = result.rigoType || result.rigo_classification?.type;
cobbAngles = result.cobbAngles || result.cobb_angles;
}
} catch (e) {
/* ignore */
}
const thumbUrl = thumbnails[caseItem.case_id];
return (
<div
key={caseItem.case_id}
className="bf-case-card bf-case-card-with-thumb"
onClick={() => navigate(`/cases/${caseItem.case_id}`)}
>
{/* X-ray Thumbnail */}
<div className="bf-case-card-thumb">
{thumbUrl ? (
<img src={thumbUrl} alt="X-ray" className="bf-case-thumb-img" />
) : (
<div className="bf-case-thumb-placeholder">
<span>X</span>
</div>
)}
</div>
<div className="bf-case-card-content">
<div className="bf-case-card-header">
<div>
<span className="bf-case-id">{caseItem.case_id}</span>
{caseItem.visit_date && (
<span className="bf-case-date">
Visit: {formatDate(caseItem.visit_date)}
</span>
)}
</div>
<span className={`bf-status-badge ${getStatusColor(caseItem.status)}`}>
{formatStatus(caseItem.status)}
</span>
</div>
<div className="bf-case-card-body">
{rigoType && (
<div className="bf-case-detail">
<span className="bf-detail-label">Rigo Type:</span>
<span className="bf-rigo-badge">{rigoType}</span>
</div>
)}
{cobbAngles && (
<div className="bf-case-detail">
<span className="bf-detail-label">Cobb Angles:</span>
<span className="bf-cobb-values">
{cobbAngles.PT !== undefined && (
<span>PT: {cobbAngles.PT.toFixed(1)}°</span>
)}
{cobbAngles.MT !== undefined && (
<span>MT: {cobbAngles.MT.toFixed(1)}°</span>
)}
{cobbAngles.TL !== undefined && (
<span>TL: {cobbAngles.TL.toFixed(1)}°</span>
)}
</span>
</div>
)}
{caseItem.body_scan_url && (
<div className="bf-case-detail">
<span className="bf-detail-icon">📷</span>
<span>Body scan uploaded</span>
</div>
)}
</div>
<div className="bf-case-card-footer">
<span className="bf-case-created">
Created: {formatDate(caseItem.created_at)}
</span>
<span className="bf-case-arrow"></span>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="bf-empty-cases">
<p>No cases yet for this patient</p>
<button
className="bf-btn bf-btn-primary"
onClick={handleCreateCase}
disabled={creatingCase}
>
Create First Case
</button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,336 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
createPatient,
getPatient,
updatePatient,
type PatientInput,
formatPatientName,
} from "../api/patientApi";
export default function PatientForm() {
const navigate = useNavigate();
const { patientId } = useParams();
const isEditing = !!patientId;
const [loading, setLoading] = useState(isEditing);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState<PatientInput>({
firstName: "",
lastName: "",
mrn: "",
dateOfBirth: "",
gender: undefined,
email: "",
phone: "",
address: "",
diagnosis: "",
curveType: "",
medicalHistory: "",
referringPhysician: "",
insuranceInfo: "",
notes: "",
});
useEffect(() => {
if (isEditing) {
loadPatient();
}
}, [patientId]);
async function loadPatient() {
try {
const { patient } = await getPatient(parseInt(patientId!));
setFormData({
firstName: patient.first_name,
lastName: patient.last_name,
mrn: patient.mrn || "",
dateOfBirth: patient.date_of_birth || "",
gender: patient.gender || undefined,
email: patient.email || "",
phone: patient.phone || "",
address: patient.address || "",
diagnosis: patient.diagnosis || "",
curveType: patient.curve_type || "",
medicalHistory: patient.medical_history || "",
referringPhysician: patient.referring_physician || "",
insuranceInfo: patient.insurance_info || "",
notes: patient.notes || "",
});
} catch (err: any) {
setError(err.message || "Failed to load patient");
} finally {
setLoading(false);
}
}
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!formData.firstName.trim() || !formData.lastName.trim()) {
setError("First name and last name are required");
return;
}
setSaving(true);
try {
if (isEditing) {
await updatePatient(parseInt(patientId!), formData);
navigate(`/patients/${patientId}`);
} else {
const { patient } = await createPatient(formData);
navigate(`/patients/${patient.id}`);
}
} catch (err: any) {
setError(err.message || "Failed to save patient");
} finally {
setSaving(false);
}
}
if (loading) {
return <div className="bf-loading">Loading patient...</div>;
}
return (
<div className="bf-patient-form-page">
<div className="bf-form-header">
<button
className="bf-back-btn"
onClick={() => navigate(isEditing ? `/patients/${patientId}` : "/patients")}
>
Back
</button>
<h1>{isEditing ? "Edit Patient" : "Add New Patient"}</h1>
</div>
{error && <div className="bf-error-message">{error}</div>}
<form onSubmit={handleSubmit} className="bf-patient-form">
{/* Basic Information */}
<section className="bf-form-section">
<h2>Basic Information</h2>
<div className="bf-form-grid">
<div className="bf-form-group">
<label htmlFor="firstName">First Name *</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
required
placeholder="Enter first name"
/>
</div>
<div className="bf-form-group">
<label htmlFor="lastName">Last Name *</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
required
placeholder="Enter last name"
/>
</div>
<div className="bf-form-group">
<label htmlFor="mrn">Medical Record Number (MRN)</label>
<input
type="text"
id="mrn"
name="mrn"
value={formData.mrn}
onChange={handleChange}
placeholder="Enter MRN (optional)"
/>
</div>
<div className="bf-form-group">
<label htmlFor="dateOfBirth">Date of Birth</label>
<input
type="date"
id="dateOfBirth"
name="dateOfBirth"
value={formData.dateOfBirth}
onChange={handleChange}
/>
</div>
<div className="bf-form-group">
<label htmlFor="gender">Gender</label>
<select
id="gender"
name="gender"
value={formData.gender || ""}
onChange={handleChange}
>
<option value="">Select gender</option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
</div>
</section>
{/* Contact Information */}
<section className="bf-form-section">
<h2>Contact Information</h2>
<div className="bf-form-grid">
<div className="bf-form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Enter email address"
/>
</div>
<div className="bf-form-group">
<label htmlFor="phone">Phone</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
placeholder="Enter phone number"
/>
</div>
<div className="bf-form-group bf-form-group-full">
<label htmlFor="address">Address</label>
<input
type="text"
id="address"
name="address"
value={formData.address}
onChange={handleChange}
placeholder="Enter address"
/>
</div>
</div>
</section>
{/* Medical Information */}
<section className="bf-form-section">
<h2>Medical Information</h2>
<div className="bf-form-grid">
<div className="bf-form-group">
<label htmlFor="diagnosis">Diagnosis</label>
<input
type="text"
id="diagnosis"
name="diagnosis"
value={formData.diagnosis}
onChange={handleChange}
placeholder="e.g., Idiopathic Scoliosis"
/>
</div>
<div className="bf-form-group">
<label htmlFor="curveType">Curve Type</label>
<select
id="curveType"
name="curveType"
value={formData.curveType || ""}
onChange={handleChange}
>
<option value="">Select curve type</option>
<option value="A1">A1 - 3 curves, main thoracic</option>
<option value="A2">A2 - 3 curves, thoracolumbar</option>
<option value="A3">A3 - 3 curves, false double major</option>
<option value="B1">B1 - 3 curves, combined</option>
<option value="B2">B2 - 3 curves, double major</option>
<option value="C1">C1 - 2 curves, main thoracic</option>
<option value="C2">C2 - 2 curves, main lumbar</option>
<option value="E1">E1 - Single curve, thoracic</option>
<option value="E2">E2 - Single curve, thoracolumbar</option>
</select>
</div>
<div className="bf-form-group">
<label htmlFor="referringPhysician">Referring Physician</label>
<input
type="text"
id="referringPhysician"
name="referringPhysician"
value={formData.referringPhysician}
onChange={handleChange}
placeholder="Enter physician name"
/>
</div>
<div className="bf-form-group">
<label htmlFor="insuranceInfo">Insurance Information</label>
<input
type="text"
id="insuranceInfo"
name="insuranceInfo"
value={formData.insuranceInfo}
onChange={handleChange}
placeholder="Enter insurance details"
/>
</div>
<div className="bf-form-group bf-form-group-full">
<label htmlFor="medicalHistory">Medical History</label>
<textarea
id="medicalHistory"
name="medicalHistory"
value={formData.medicalHistory}
onChange={handleChange}
rows={4}
placeholder="Enter relevant medical history..."
/>
</div>
<div className="bf-form-group bf-form-group-full">
<label htmlFor="notes">Notes</label>
<textarea
id="notes"
name="notes"
value={formData.notes}
onChange={handleChange}
rows={3}
placeholder="Additional notes..."
/>
</div>
</div>
</section>
{/* Form Actions */}
<div className="bf-form-actions">
<button
type="button"
className="bf-btn bf-btn-outline"
onClick={() => navigate(isEditing ? `/patients/${patientId}` : "/patients")}
>
Cancel
</button>
<button type="submit" className="bf-btn bf-btn-primary" disabled={saving}>
{saving ? "Saving..." : isEditing ? "Update Patient" : "Create Patient"}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,264 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
listPatients,
archivePatient,
type Patient,
formatPatientName,
calculateAge,
formatDate,
} from "../api/patientApi";
export default function PatientList() {
const navigate = useNavigate();
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(0);
// Filters
const [search, setSearch] = useState("");
const [limit] = useState(20);
const [offset, setOffset] = useState(0);
// Archive confirmation
const [archiveConfirm, setArchiveConfirm] = useState<Patient | null>(null);
useEffect(() => {
loadPatients();
}, [search, offset]);
async function loadPatients() {
setLoading(true);
setError(null);
try {
const data = await listPatients({
search: search || undefined,
isActive: true,
limit,
offset,
});
setPatients(data.patients);
setTotal(data.total);
} catch (err: any) {
setError(err.message || "Failed to load patients");
} finally {
setLoading(false);
}
}
async function handleArchive(patient: Patient) {
try {
await archivePatient(patient.id);
setArchiveConfirm(null);
loadPatients();
} catch (err: any) {
setError(err.message || "Failed to archive patient");
}
}
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
setSearch(e.target.value);
setOffset(0);
}
const totalPages = Math.ceil(total / limit);
const currentPage = Math.floor(offset / limit) + 1;
return (
<div className="bf-patients-page">
<div className="bf-patients-header">
<div>
<h1>Patients</h1>
<p className="bf-patients-subtitle">
Manage patient records and create new cases
</p>
</div>
<button
className="bf-btn bf-btn-primary bf-add-patient-btn"
onClick={() => navigate("/patients/new")}
>
<span className="bf-btn-icon">+</span>
Add Patient
</button>
</div>
{/* Filters */}
<div className="bf-patients-filters">
<div className="bf-search-box">
<input
type="text"
placeholder="Search by name, MRN, or email..."
value={search}
onChange={handleSearchChange}
className="bf-search-input"
/>
</div>
</div>
{error && <div className="bf-error-message">{error}</div>}
{loading ? (
<div className="bf-loading">Loading patients...</div>
) : patients.length > 0 ? (
<>
<div className="bf-patients-grid">
{patients.map((patient) => (
<div
key={patient.id}
className={`bf-patient-card ${!patient.is_active ? "inactive" : ""}`}
onClick={() => navigate(`/patients/${patient.id}`)}
>
<div className="bf-patient-card-header">
<div className="bf-patient-avatar">
{patient.first_name[0]}
{patient.last_name[0]}
</div>
<div className="bf-patient-info">
<h3 className="bf-patient-name">
{formatPatientName(patient)}
</h3>
{patient.mrn && (
<span className="bf-patient-mrn">MRN: {patient.mrn}</span>
)}
</div>
{!patient.is_active && (
<span className="bf-badge bf-badge-inactive">Inactive</span>
)}
</div>
<div className="bf-patient-card-body">
<div className="bf-patient-details">
{patient.date_of_birth && (
<div className="bf-patient-detail">
<span className="bf-detail-label">Age:</span>
<span className="bf-detail-value">
{calculateAge(patient.date_of_birth)} years
</span>
</div>
)}
{patient.gender && (
<div className="bf-patient-detail">
<span className="bf-detail-label">Gender:</span>
<span className="bf-detail-value bf-capitalize">
{patient.gender}
</span>
</div>
)}
{patient.diagnosis && (
<div className="bf-patient-detail">
<span className="bf-detail-label">Diagnosis:</span>
<span className="bf-detail-value">{patient.diagnosis}</span>
</div>
)}
</div>
<div className="bf-patient-stats">
<div className="bf-patient-stat">
<span className="bf-stat-value">{patient.case_count || 0}</span>
<span className="bf-stat-label">Cases</span>
</div>
{patient.last_visit && (
<div className="bf-patient-stat">
<span className="bf-stat-value">{formatDate(patient.last_visit)}</span>
<span className="bf-stat-label">Last Visit</span>
</div>
)}
</div>
</div>
<div className="bf-patient-card-actions" onClick={(e) => e.stopPropagation()}>
<button
className="bf-btn bf-btn-small bf-btn-outline"
onClick={() => navigate(`/patients/${patient.id}/edit`)}
>
Edit
</button>
<button
className="bf-btn bf-btn-small bf-btn-outline bf-btn-danger"
onClick={() => setArchiveConfirm(patient)}
>
Delete
</button>
</div>
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="bf-pagination">
<span className="bf-pagination-info">
Showing {offset + 1} - {Math.min(offset + limit, total)} of {total} patients
</span>
<div className="bf-pagination-controls">
<button
className="bf-btn bf-btn-small"
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - limit))}
>
Previous
</button>
<span className="bf-pagination-page">
Page {currentPage} of {totalPages}
</span>
<button
className="bf-btn bf-btn-small"
disabled={offset + limit >= total}
onClick={() => setOffset(offset + limit)}
>
Next
</button>
</div>
</div>
)}
</>
) : (
<div className="bf-empty-state">
<div className="bf-empty-icon">👤</div>
<h3>No patients found</h3>
<p>
{search
? "Try adjusting your search criteria"
: "Get started by adding your first patient"}
</p>
{!search && (
<button
className="bf-btn bf-btn-primary"
onClick={() => navigate("/patients/new")}
>
Add First Patient
</button>
)}
</div>
)}
{/* Delete Confirmation Modal (archives behind the scenes) */}
{archiveConfirm && (
<div className="bf-modal-backdrop" onClick={() => setArchiveConfirm(null)}>
<div className="bf-modal" onClick={(e) => e.stopPropagation()}>
<h2>Delete Patient</h2>
<p>
Are you sure you want to delete{" "}
<strong>{formatPatientName(archiveConfirm)}</strong>?
</p>
<div className="bf-modal-actions">
<button
className="bf-btn bf-btn-outline"
onClick={() => setArchiveConfirm(null)}
>
Cancel
</button>
<button
className="bf-btn bf-btn-danger"
onClick={() => handleArchive(archiveConfirm)}
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -422,13 +422,33 @@ export default function PipelineCaseDetail() {
{/* Header */}
<header className="pipeline-header">
<div className="header-left">
<button className="back-btn" onClick={() => nav('/')}>
<button
className="back-btn"
onClick={() => caseData?.patient_id ? nav(`/patients/${caseData.patient_id}`) : nav('/patients')}
>
Back
</button>
<h1 className="case-title">{caseId}</h1>
<span className={`status-badge status-${caseData?.status || 'created'}`}>
{caseData?.status?.replace(/_/g, ' ') || 'Created'}
</span>
<div className="case-header-info">
{caseData?.patient && (
<h1 className="patient-name-header">
{caseData.patient.fullName}
{caseData.patient.mrn && (
<span className="patient-mrn-badge">MRN: {caseData.patient.mrn}</span>
)}
</h1>
)}
<div className="case-meta-row">
<span className="case-id-label">{caseId}</span>
{caseData?.visit_date && (
<span className="visit-date-label">
Visit: {new Date(caseData.visit_date).toLocaleDateString()}
</span>
)}
<span className={`status-badge status-${caseData?.status || 'created'}`}>
{caseData?.status?.replace(/_/g, ' ') || 'Created'}
</span>
</div>
</div>
</div>
</header>

View File

@@ -1,150 +1,674 @@
import { useEffect, useState } from "react";
import { getAuditLog, type AuditLogEntry } from "../../api/adminApi";
import {
getApiActivity,
getApiActivityStats,
type ApiRequestEntry,
type ApiActivityStats,
type FileUploadInfo,
type ResponseSummary,
} from "../../api/adminApi";
export default function AdminActivity() {
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
const [requests, setRequests] = useState<ApiRequestEntry[]>([]);
const [stats, setStats] = useState<ApiActivityStats | null>(null);
const [loading, setLoading] = useState(true);
const [statsLoading, setStatsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(0);
// Expanded row
const [expandedId, setExpandedId] = useState<number | null>(null);
// Filters
const [actionFilter, setActionFilter] = useState("");
const [methodFilter, setMethodFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [pathFilter, setPathFilter] = useState("");
const [usernameFilter, setUsernameFilter] = useState("");
const [limit, setLimit] = useState(50);
const [offset, setOffset] = useState(0);
// View mode
const [viewMode, setViewMode] = useState<"list" | "stats">("list");
useEffect(() => {
loadAuditLog();
}, [actionFilter, limit]);
loadActivity();
}, [methodFilter, statusFilter, pathFilter, usernameFilter, limit, offset]);
async function loadAuditLog() {
useEffect(() => {
if (viewMode === "stats") {
loadStats();
}
}, [viewMode]);
async function loadActivity() {
setLoading(true);
setError(null);
try {
const data = await getAuditLog({
action: actionFilter || undefined,
const data = await getApiActivity({
method: methodFilter || undefined,
statusCategory: (statusFilter as "2xx" | "3xx" | "4xx" | "5xx") || undefined,
path: pathFilter || undefined,
username: usernameFilter || undefined,
limit,
offset,
});
setEntries(data.entries);
setRequests(data.requests);
setTotal(data.total);
} catch (err: any) {
setError(err.message || "Failed to load audit log");
setError(err.message || "Failed to load API activity");
} finally {
setLoading(false);
}
}
function formatAction(action: string): string {
return action.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function getActionColor(action: string): string {
if (action.includes("delete")) return "bf-action-danger";
if (action.includes("create")) return "bf-action-success";
if (action.includes("update")) return "bf-action-warning";
if (action.includes("login")) return "bf-action-info";
return "bf-action-default";
}
function parseDetails(details: string | null): Record<string, any> | null {
if (!details) return null;
async function loadStats() {
setStatsLoading(true);
try {
return JSON.parse(details);
const data = await getApiActivityStats();
setStats(data.stats);
} catch (err: any) {
console.error("Failed to load stats:", err);
} finally {
setStatsLoading(false);
}
}
function getMethodColor(method: string): string {
switch (method.toUpperCase()) {
case "GET": return "bf-method-get";
case "POST": return "bf-method-post";
case "PUT": return "bf-method-put";
case "DELETE": return "bf-method-delete";
case "PATCH": return "bf-method-patch";
default: return "bf-method-default";
}
}
function getStatusColor(status: number | null): string {
if (!status) return "bf-status-unknown";
if (status >= 200 && status < 300) return "bf-status-success";
if (status >= 300 && status < 400) return "bf-status-redirect";
if (status >= 400 && status < 500) return "bf-status-client-error";
if (status >= 500) return "bf-status-server-error";
return "bf-status-unknown";
}
function formatResponseTime(ms: number | null): string {
if (!ms) return "-";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function formatBytes(bytes: number | null): string {
if (!bytes) return "-";
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
function parseJson<T>(str: string | null): T | null {
if (!str) return null;
try {
return JSON.parse(str) as T;
} catch {
return null;
}
}
const uniqueActions = [...new Set(entries.map((e) => e.action))].sort();
function toggleExpand(id: number) {
setExpandedId(expandedId === id ? null : id);
}
const totalPages = Math.ceil(total / limit);
const currentPage = Math.floor(offset / limit) + 1;
// Render request parameters
function renderRequestParams(params: Record<string, any> | null) {
if (!params || Object.keys(params).length === 0) return null;
return (
<div className="bf-activity-params">
<h4>Request Parameters</h4>
<div className="bf-params-grid">
{Object.entries(params).map(([key, value]) => (
<div key={key} className="bf-param-item">
<span className="bf-param-key">{key}</span>
<span className="bf-param-value">
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</span>
</div>
))}
</div>
</div>
);
}
// Render file uploads
function renderFileUploads(files: FileUploadInfo[] | null) {
if (!files || files.length === 0) return null;
return (
<div className="bf-activity-files">
<h4>File Uploads</h4>
{files.map((file, idx) => (
<div key={idx} className="bf-file-item">
<span className="bf-file-icon">📁</span>
<div className="bf-file-details">
<span className="bf-file-name">{file.originalname}</span>
<span className="bf-file-meta">
{file.mimetype} {formatBytes(file.size)}
{file.filename && <> <code>{file.filename}</code></>}
</span>
</div>
</div>
))}
</div>
);
}
// Render response summary
function renderResponseSummary(summary: ResponseSummary | null, statusCode: number | null) {
if (!summary || Object.keys(summary).length === 0) return null;
return (
<div className="bf-activity-response">
<h4>Response Summary</h4>
<div className="bf-response-items">
{summary.success !== undefined && (
<div className={`bf-response-item ${summary.success ? 'bf-res-success' : 'bf-res-error'}`}>
<span className="bf-res-icon">{summary.success ? '✓' : '✗'}</span>
<span>{summary.success ? 'Success' : 'Failed'}</span>
</div>
)}
{summary.message && (
<div className="bf-response-item bf-res-message">
<span className="bf-res-label">Message:</span>
<span>{summary.message}</span>
</div>
)}
{summary.error && (
<div className="bf-response-item bf-res-error">
<span className="bf-res-label">Error:</span>
<span>{summary.error}</span>
</div>
)}
{summary.caseId && (
<div className="bf-response-item">
<span className="bf-res-label">Case ID:</span>
<code className="bf-res-code">{summary.caseId}</code>
</div>
)}
{summary.status && (
<div className="bf-response-item">
<span className="bf-res-label">Status:</span>
<span className="bf-res-badge">{summary.status}</span>
</div>
)}
{summary.tokenGenerated && (
<div className="bf-response-item bf-res-success">
<span className="bf-res-icon">🔑</span>
<span>Auth token generated</span>
</div>
)}
{summary.username && (
<div className="bf-response-item">
<span className="bf-res-label">User:</span>
<span>{summary.username}</span>
</div>
)}
{summary.rigoType && (
<div className="bf-response-item bf-res-highlight">
<span className="bf-res-label">Rigo Type:</span>
<span className="bf-res-badge bf-rigo-badge">{summary.rigoType}</span>
</div>
)}
{summary.cobbAngles && (
<div className="bf-response-item">
<span className="bf-res-label">Cobb Angles:</span>
<span className="bf-cobb-angles">
{summary.cobbAngles.PT !== undefined && <span>PT: {summary.cobbAngles.PT.toFixed(1)}°</span>}
{summary.cobbAngles.MT !== undefined && <span>MT: {summary.cobbAngles.MT.toFixed(1)}°</span>}
{summary.cobbAngles.TL !== undefined && <span>TL: {summary.cobbAngles.TL.toFixed(1)}°</span>}
</span>
</div>
)}
{summary.vertebraeDetected && (
<div className="bf-response-item">
<span className="bf-res-label">Vertebrae:</span>
<span>{summary.vertebraeDetected} detected</span>
</div>
)}
{summary.landmarksCount && (
<div className="bf-response-item">
<span className="bf-res-label">Landmarks:</span>
<span>{summary.landmarksCount}</span>
</div>
)}
{summary.bracesGenerated && (
<div className="bf-response-item bf-res-highlight">
<span className="bf-res-icon">🦴</span>
<span>Braces: </span>
{summary.bracesGenerated.regular && <span className="bf-brace-tag regular">Regular</span>}
{summary.bracesGenerated.vase && <span className="bf-brace-tag vase">Vase</span>}
</div>
)}
{summary.braceGenerated && (
<div className="bf-response-item bf-res-highlight">
<span className="bf-res-icon">🦴</span>
<span>Brace generated</span>
{summary.braceVertices && <span className="bf-res-small">({summary.braceVertices.toLocaleString()} vertices)</span>}
</div>
)}
{summary.filesGenerated && summary.filesGenerated.length > 0 && (
<div className="bf-response-item">
<span className="bf-res-label">Files:</span>
<span className="bf-file-tags">
{summary.filesGenerated.map(f => (
<span key={f} className="bf-file-tag">{f}</span>
))}
</span>
</div>
)}
{summary.outputUrl && (
<div className="bf-response-item">
<span className="bf-res-label">Output:</span>
<code className="bf-res-code">{summary.outputUrl}</code>
</div>
)}
{summary.bodyScanUploaded && (
<div className="bf-response-item bf-res-success">
<span className="bf-res-icon">📷</span>
<span>Body scan uploaded</span>
</div>
)}
{summary.measurementsExtracted && (
<div className="bf-response-item bf-res-success">
<span className="bf-res-icon">📏</span>
<span>Measurements extracted</span>
</div>
)}
{summary.casesCount !== undefined && (
<div className="bf-response-item">
<span className="bf-res-label">Cases:</span>
<span>{summary.casesCount} returned</span>
</div>
)}
{summary.usersCount !== undefined && (
<div className="bf-response-item">
<span className="bf-res-label">Users:</span>
<span>{summary.usersCount} returned</span>
</div>
)}
{summary.total !== undefined && !summary.casesCount && !summary.usersCount && (
<div className="bf-response-item">
<span className="bf-res-label">Total:</span>
<span>{summary.total}</span>
</div>
)}
</div>
</div>
);
}
return (
<div className="bf-admin-page">
<div className="bf-admin-header">
<div>
<h1>Activity Log</h1>
<p className="bf-admin-subtitle">Audit trail of system actions</p>
<h1>API Activity</h1>
<p className="bf-admin-subtitle">
Track all HTTP API requests made by users
</p>
</div>
<button className="btn" onClick={loadAuditLog} disabled={loading}>
Refresh
</button>
</div>
{/* Filters */}
<div className="bf-admin-filters">
<select
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
className="bf-admin-filter-select"
>
<option value="">All Actions</option>
{uniqueActions.map((action) => (
<option key={action} value={action}>{formatAction(action)}</option>
))}
</select>
<select
value={limit}
onChange={(e) => setLimit(parseInt(e.target.value))}
className="bf-admin-filter-select"
>
<option value="25">Last 25</option>
<option value="50">Last 50</option>
<option value="100">Last 100</option>
<option value="200">Last 200</option>
</select>
</div>
{error && <div className="bf-admin-error">{error}</div>}
{loading ? (
<div className="bf-admin-loading">Loading activity log...</div>
) : entries.length > 0 ? (
<div className="bf-admin-card">
<div className="bf-admin-activity-list">
{entries.map((entry) => {
const details = parseDetails(entry.details);
return (
<div key={entry.id} className="bf-admin-activity-item">
<div className="bf-admin-activity-icon">
<span className={`bf-admin-activity-dot ${getActionColor(entry.action)}`} />
</div>
<div className="bf-admin-activity-content">
<div className="bf-admin-activity-header">
<span className={`bf-admin-activity-action ${getActionColor(entry.action)}`}>
{formatAction(entry.action)}
</span>
<span className="bf-admin-activity-entity">
{entry.entity_type}
{entry.entity_id && `: ${entry.entity_id}`}
</span>
</div>
<div className="bf-admin-activity-meta">
<span className="bf-admin-activity-user">
{entry.username || "System"}
</span>
<span className="bf-admin-activity-time">
{new Date(entry.created_at).toLocaleString()}
</span>
{entry.ip_address && (
<span className="bf-admin-activity-ip">IP: {entry.ip_address}</span>
)}
</div>
{details && Object.keys(details).length > 0 && (
<div className="bf-admin-activity-details">
{Object.entries(details).map(([key, value]) => (
<span key={key} className="bf-admin-activity-detail">
<strong>{key}:</strong> {String(value)}
</span>
))}
</div>
)}
</div>
</div>
);
})}
<div className="bf-admin-header-actions">
<div className="bf-admin-view-toggle">
<button
className={`bf-toggle-btn ${viewMode === "list" ? "active" : ""}`}
onClick={() => setViewMode("list")}
>
Request Log
</button>
<button
className={`bf-toggle-btn ${viewMode === "stats" ? "active" : ""}`}
onClick={() => setViewMode("stats")}
>
Statistics
</button>
</div>
<button className="btn" onClick={loadActivity} disabled={loading}>
Refresh
</button>
</div>
</div>
{viewMode === "stats" ? (
// Statistics View
<div className="bf-admin-stats-section">
{statsLoading ? (
<div className="bf-admin-loading">Loading statistics...</div>
) : stats ? (
<>
{/* Summary Cards */}
<div className="bf-admin-stats-grid">
<div className="bf-admin-stat-card">
<div className="bf-admin-stat-value">{stats.total.toLocaleString()}</div>
<div className="bf-admin-stat-label">Total Requests</div>
</div>
<div className="bf-admin-stat-card">
<div className="bf-admin-stat-value">{stats.responseTime.avg}ms</div>
<div className="bf-admin-stat-label">Avg Response Time</div>
</div>
<div className="bf-admin-stat-card bf-stat-warning">
<div className="bf-admin-stat-value">{stats.errorRate}%</div>
<div className="bf-admin-stat-label">Error Rate</div>
</div>
<div className="bf-admin-stat-card">
<div className="bf-admin-stat-value">{stats.topUsers.length}</div>
<div className="bf-admin-stat-label">Active Users</div>
</div>
</div>
{/* Method Distribution */}
<div className="bf-admin-card">
<h3>Requests by Method</h3>
<div className="bf-admin-method-distribution">
{Object.entries(stats.byMethod).map(([method, count]) => (
<div key={method} className="bf-method-bar-container">
<span className={`bf-method-label ${getMethodColor(method)}`}>
{method}
</span>
<div className="bf-method-bar-wrapper">
<div
className={`bf-method-bar ${getMethodColor(method)}`}
style={{
width: `${(count / stats.total) * 100}%`,
}}
/>
</div>
<span className="bf-method-count">{count.toLocaleString()}</span>
</div>
))}
</div>
</div>
{/* Status Distribution */}
<div className="bf-admin-card">
<h3>Requests by Status</h3>
<div className="bf-admin-status-distribution">
{Object.entries(stats.byStatusCategory).map(([category, count]) => (
<div key={category} className="bf-status-item">
<span className={`bf-status-label ${
category.includes("2xx") ? "bf-status-success" :
category.includes("4xx") ? "bf-status-client-error" :
category.includes("5xx") ? "bf-status-server-error" :
"bf-status-redirect"
}`}>
{category}
</span>
<span className="bf-status-count">{count.toLocaleString()}</span>
</div>
))}
</div>
</div>
{/* Top Endpoints */}
<div className="bf-admin-card">
<h3>Top Endpoints</h3>
<table className="bf-admin-table">
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Requests</th>
<th>Avg Time</th>
</tr>
</thead>
<tbody>
{stats.topEndpoints.slice(0, 10).map((endpoint, idx) => (
<tr key={idx}>
<td>
<span className={`bf-method-badge ${getMethodColor(endpoint.method)}`}>
{endpoint.method}
</span>
</td>
<td className="bf-endpoint-path">{endpoint.path}</td>
<td>{endpoint.count.toLocaleString()}</td>
<td>{Math.round(endpoint.avg_response_time || 0)}ms</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Top Users */}
<div className="bf-admin-card">
<h3>Most Active Users</h3>
<table className="bf-admin-table">
<thead>
<tr>
<th>User</th>
<th>Requests</th>
</tr>
</thead>
<tbody>
{stats.topUsers.map((user, idx) => (
<tr key={idx}>
<td>{user.username || "Anonymous"}</td>
<td>{user.count.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : (
<div className="bf-admin-empty-state">
<p>No statistics available</p>
</div>
)}
</div>
) : (
<div className="bf-admin-empty-state">
<p>No activity recorded yet</p>
</div>
// List View
<>
{/* Filters */}
<div className="bf-admin-filters">
<select
value={methodFilter}
onChange={(e) => { setMethodFilter(e.target.value); setOffset(0); }}
className="bf-admin-filter-select"
>
<option value="">All Methods</option>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
</select>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setOffset(0); }}
className="bf-admin-filter-select"
>
<option value="">All Status</option>
<option value="2xx">2xx Success</option>
<option value="3xx">3xx Redirect</option>
<option value="4xx">4xx Client Error</option>
<option value="5xx">5xx Server Error</option>
</select>
<input
type="text"
placeholder="Filter by path..."
value={pathFilter}
onChange={(e) => { setPathFilter(e.target.value); setOffset(0); }}
className="bf-admin-filter-input"
/>
<input
type="text"
placeholder="Filter by user..."
value={usernameFilter}
onChange={(e) => { setUsernameFilter(e.target.value); setOffset(0); }}
className="bf-admin-filter-input"
/>
<select
value={limit}
onChange={(e) => { setLimit(parseInt(e.target.value)); setOffset(0); }}
className="bf-admin-filter-select"
>
<option value="25">25 per page</option>
<option value="50">50 per page</option>
<option value="100">100 per page</option>
</select>
</div>
{error && <div className="bf-admin-error">{error}</div>}
{loading ? (
<div className="bf-admin-loading">Loading API activity...</div>
) : requests.length > 0 ? (
<>
<div className="bf-admin-card">
<div className="bf-activity-list">
{requests.map((req) => {
const queryParams = parseJson<Record<string, any>>(req.query_params);
const requestParams = parseJson<Record<string, any>>(req.request_params);
const fileUploads = parseJson<FileUploadInfo[]>(req.file_uploads);
const responseSummary = parseJson<ResponseSummary>(req.response_summary);
const isExpanded = expandedId === req.id;
const hasDetails = requestParams || fileUploads || responseSummary || queryParams;
return (
<div
key={req.id}
className={`bf-activity-row ${isExpanded ? 'expanded' : ''} ${hasDetails ? 'has-details' : ''}`}
>
<div
className="bf-activity-main"
onClick={() => hasDetails && toggleExpand(req.id)}
>
<div className="bf-activity-left">
<span className={`bf-method-badge ${getMethodColor(req.method)}`}>
{req.method}
</span>
<span className="bf-activity-path">
{req.path}
{queryParams && Object.keys(queryParams).length > 0 && (
<span className="bf-query-indicator" title={JSON.stringify(queryParams, null, 2)}>
?{Object.keys(queryParams).length}
</span>
)}
</span>
</div>
<div className="bf-activity-center">
<span className="bf-activity-user">
{req.username || <span className="bf-anonymous">anonymous</span>}
</span>
<span className={`bf-status-badge ${getStatusColor(req.status_code)}`}>
{req.status_code || "-"}
</span>
<span className="bf-activity-time-value">
{formatResponseTime(req.response_time_ms)}
</span>
</div>
<div className="bf-activity-right">
<span className="bf-activity-timestamp">
{new Date(req.created_at).toLocaleString()}
</span>
{hasDetails && (
<span className={`bf-expand-icon ${isExpanded ? 'expanded' : ''}`}>
</span>
)}
</div>
</div>
{isExpanded && hasDetails && (
<div className="bf-activity-details">
{queryParams && Object.keys(queryParams).length > 0 && (
<div className="bf-activity-params">
<h4>Query Parameters</h4>
<div className="bf-params-grid">
{Object.entries(queryParams).map(([key, value]) => (
<div key={key} className="bf-param-item">
<span className="bf-param-key">{key}</span>
<span className="bf-param-value">{String(value)}</span>
</div>
))}
</div>
</div>
)}
{renderRequestParams(requestParams)}
{renderFileUploads(fileUploads)}
{renderResponseSummary(responseSummary, req.status_code)}
<div className="bf-activity-meta-details">
<span>IP: {req.ip_address || 'unknown'}</span>
<span>Request: {formatBytes(req.request_body_size)}</span>
<span>Response: {formatBytes(req.response_body_size)}</span>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Pagination */}
<div className="bf-admin-pagination">
<span className="bf-pagination-info">
Showing {offset + 1} - {Math.min(offset + limit, total)} of {total.toLocaleString()} requests
</span>
<div className="bf-pagination-controls">
<button
className="bf-pagination-btn"
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - limit))}
>
Previous
</button>
<span className="bf-pagination-page">
Page {currentPage} of {totalPages}
</span>
<button
className="bf-pagination-btn"
disabled={offset + limit >= total}
onClick={() => setOffset(offset + limit)}
>
Next
</button>
</div>
</div>
</>
) : (
<div className="bf-admin-empty-state">
<p>No API activity recorded yet</p>
<p className="bf-admin-empty-hint">
API requests will appear here as users interact with the system
</p>
</div>
)}
</>
)}
</div>
);

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { listCasesAdmin, type AdminCase, type ListCasesResponse } from "../../api/adminApi";
import { listCasesAdmin, restoreCase, type AdminCase, type ListCasesResponse } from "../../api/adminApi";
const STATUS_OPTIONS = [
{ value: "", label: "All Statuses" },
@@ -13,6 +13,12 @@ const STATUS_OPTIONS = [
{ value: "brace_failed", label: "Brace Failed" },
];
const ARCHIVE_OPTIONS = [
{ value: "active", label: "Active Cases" },
{ value: "all", label: "All Cases" },
{ value: "archived", label: "Deleted Only" },
];
const PAGE_SIZE = 20;
export default function AdminCases() {
@@ -21,9 +27,11 @@ export default function AdminCases() {
const [data, setData] = useState<ListCasesResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [restoring, setRestoring] = useState<string | null>(null);
// Filters
const [statusFilter, setStatusFilter] = useState("");
const [archiveFilter, setArchiveFilter] = useState("active");
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState("created_at");
const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC");
@@ -40,6 +48,8 @@ export default function AdminCases() {
sortOrder,
limit: PAGE_SIZE,
offset: currentPage * PAGE_SIZE,
includeArchived: archiveFilter === "all",
archivedOnly: archiveFilter === "archived",
});
setData(result);
} catch (err: any) {
@@ -47,7 +57,19 @@ export default function AdminCases() {
} finally {
setLoading(false);
}
}, [statusFilter, searchQuery, sortBy, sortOrder, currentPage]);
}, [statusFilter, archiveFilter, searchQuery, sortBy, sortOrder, currentPage]);
async function handleRestore(caseId: string) {
setRestoring(caseId);
try {
await restoreCase(caseId);
loadCases();
} catch (err: any) {
setError(err.message || "Failed to restore case");
} finally {
setRestoring(null);
}
}
useEffect(() => {
loadCases();
@@ -156,6 +178,19 @@ export default function AdminCases() {
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<select
value={archiveFilter}
onChange={(e) => {
setArchiveFilter(e.target.value);
setCurrentPage(0);
}}
className="bf-admin-filter-select"
>
{ARCHIVE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{error && <div className="bf-admin-error">{error}</div>}
@@ -206,6 +241,9 @@ export default function AdminCases() {
<span className={`bf-admin-case-status ${getStatusColor(c.status)}`}>
{c.status.replace(/_/g, " ")}
</span>
{c.is_archived && (
<span className="bf-admin-archived-badge">Deleted</span>
)}
</td>
<td>
{rigoType ? (
@@ -235,13 +273,22 @@ export default function AdminCases() {
{new Date(c.created_at).toLocaleTimeString()}
</span>
</td>
<td>
<td className="bf-admin-actions-cell">
<button
className="bf-admin-action-btn"
onClick={() => navigate(`/cases/${c.caseId}`)}
>
View
</button>
{c.is_archived && (
<button
className="bf-admin-action-btn bf-admin-restore-btn"
onClick={() => handleRestore(c.caseId)}
disabled={restoring === c.caseId}
>
{restoring === c.caseId ? "..." : "Restore"}
</button>
)}
</td>
</tr>
);

File diff suppressed because it is too large Load Diff

View File

@@ -4,4 +4,18 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Proxy API requests to Docker nginx (which proxies to API container)
'/api': {
target: 'http://localhost:80',
changeOrigin: true,
},
// Proxy file requests
'/files': {
target: 'http://localhost:80',
changeOrigin: true,
},
},
},
})