Add patient management, deployment scripts, and Docker fixes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
248
frontend/src/api/patientApi.ts
Normal file
248
frontend/src/api/patientApi.ts
Normal 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();
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
338
frontend/src/pages/PatientDetail.tsx
Normal file
338
frontend/src/pages/PatientDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
336
frontend/src/pages/PatientForm.tsx
Normal file
336
frontend/src/pages/PatientForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
frontend/src/pages/PatientList.tsx
Normal file
264
frontend/src/pages/PatientList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user