Ensure data management page grabs progress of any running scripts on load, clean up unneeded console logs, restyle login page

This commit is contained in:
2025-03-28 19:26:52 -04:00
parent a068a253cd
commit 1e0be3f86e
11 changed files with 599 additions and 336 deletions

View File

@@ -2,9 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/box.svg" /> <link rel="icon" type="image/x-icon" href="/cherrybottom.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Inventory Manager</title> <title>A Cherry On Bottom</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7l8.7 5l8.7-5M12 22V12"/></g></svg>

Before

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,42 +1,3 @@
#root { #root {
max-width: 1800px; font-family: 'Inter', sans-serif;
margin: 0 auto; }
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -3,7 +3,6 @@ import {
Package, Package,
BarChart2, BarChart2,
Settings, Settings,
Box,
ClipboardList, ClipboardList,
LogOut, LogOut,
Users, Users,
@@ -22,6 +21,7 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarSeparator, SidebarSeparator,
useSidebar
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useLocation, useNavigate, Link } from "react-router-dom"; import { useLocation, useNavigate, Link } from "react-router-dom";
import { Protected } from "@/components/auth/Protected"; import { Protected } from "@/components/auth/Protected";
@@ -80,6 +80,7 @@ const items = [
export function AppSidebar() { export function AppSidebar() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
useSidebar();
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('token'); localStorage.removeItem('token');
@@ -90,11 +91,19 @@ export function AppSidebar() {
return ( return (
<Sidebar collapsible="icon" variant="sidebar"> <Sidebar collapsible="icon" variant="sidebar">
<SidebarHeader> <SidebarHeader>
<div className="p-4 flex items-center gap-2 group-data-[collapsible=icon]:justify-center"> <div className="py-1 flex justify-center items-center">
<Box className="h-6 w-6 shrink-0" /> <div className="flex items-center">
<h2 className="text-lg font-semibold group-data-[collapsible=icon]:hidden"> <div className="flex-shrink-0 w-8 h-8 relative flex items-center justify-center">
Inventory Manager <img
</h2> src="/cherrybottom.png"
alt="Cherry Bottom"
className="w-6 h-6 object-contain -rotate-12 transform hover:rotate-0 transition-transform ease-in-out duration-300"
/>
</div>
<div className="ml-2 transition-all duration-200 whitespace-nowrap group-[.group[data-state=collapsed]]:hidden">
<span className="font-bold text-lg">A Cherry On Bottom</span>
</div>
</div>
</div> </div>
</SidebarHeader> </SidebarHeader>
<SidebarSeparator /> <SidebarSeparator />

View File

@@ -28,6 +28,7 @@ import { Loader2, X, RefreshCw, AlertTriangle, RefreshCcw, Hourglass } from "luc
import config from "../../config"; import config from "../../config";
import { toast } from "sonner"; import { toast } from "sonner";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
interface HistoryRecord { interface HistoryRecord {
@@ -78,6 +79,75 @@ interface GroupedTableCounts {
config: TableCount[]; config: TableCount[];
} }
// TableSkeleton component for consistent loading states
interface TableSkeletonProps {
rows?: number;
columns?: number;
useAccordion?: boolean;
}
const TableSkeleton = ({ rows = 5, columns = 4, useAccordion = false }: TableSkeletonProps) => {
return (
<Table>
<TableBody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<TableRow key={rowIndex} className="hover:bg-transparent">
<TableCell className="w-full p-0">
{useAccordion ? (
<Accordion type="single" collapsible>
<AccordionItem value={`skeleton-${rowIndex}`} className="border-0">
<AccordionTrigger className="px-4 py-2 cursor-default">
<div className="flex justify-between items-center w-full pr-4">
<div className="w-[50px]">
<Skeleton className="h-4 w-full" />
</div>
<div className="w-[170px]">
<Skeleton className="h-4 w-full" />
</div>
<div className="w-[140px]">
<Skeleton className="h-4 w-full" />
</div>
<div className="w-[80px]">
<Skeleton className="h-4 w-full" />
</div>
</div>
</AccordionTrigger>
</AccordionItem>
</Accordion>
) : (
<div className="flex justify-between px-4 py-2">
<Skeleton className="h-4 w-[180px]" />
<Skeleton className="h-4 w-[100px]" />
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};
// Empty state skeleton that maintains consistent sizing
const EmptyStateSkeleton = ({ message = "No data available" }: { message?: string }) => {
return (
<TableRow>
<TableCell className="text-center py-12">
<div className="flex flex-col items-center justify-center min-h-[180px]">
<p className="text-sm text-muted-foreground">{message}</p>
</div>
</TableCell>
</TableRow>
);
};
// Create a component for the empty state of status cards
const StatusEmptyState = ({ message }: { message: React.ReactNode }) => (
<div className="flex flex-col items-center justify-center py-6 min-h-[150px]">
<p className="text-sm text-muted-foreground text-center">{message}</p>
</div>
);
export function DataManagement() { export function DataManagement() {
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [isResetting, setIsResetting] = useState(false); const [isResetting, setIsResetting] = useState(false);
@@ -94,6 +164,149 @@ export function DataManagement() {
// Add useRef for scroll handling // Add useRef for scroll handling
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
// Check if there's an active script running on component mount
const checkActiveProcess = async () => {
try {
console.log("Checking for active processes...");
const response = await fetch(`${config.apiUrl}/csv/status`, {
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to check active process status");
}
const data = await response.json();
console.log("Active process check response:", data);
if (data.active && data.progress) {
console.log("Active process detected:", data.progress);
// Determine if it's a reset or update based on the progress data
const isReset =
data.progress.operation?.includes("reset") ||
data.progress.operation?.includes("Reset");
// Set the appropriate state
if (isReset) {
console.log("Reconnecting to reset process...");
setIsResetting(true);
// Connect to reset SSE endpoint
connectToEventSource("reset");
} else {
console.log("Reconnecting to update process...");
setIsUpdating(true);
// Connect to update SSE endpoint
connectToEventSource("update");
}
// If we have progress data, initialize the output
if (data.progress) {
setScriptOutput([JSON.stringify(data.progress)]);
}
} else {
console.log("No active processes detected");
}
} catch (error) {
console.error("Error checking for active processes:", error);
}
};
// Function to connect to the appropriate SSE endpoint
const connectToEventSource = (type: "update" | "reset") => {
if (eventSource) {
eventSource.close();
}
// The correct URL structure is /api/csv/{type}/progress
const sseUrl = `${config.apiUrl}/csv/${type}/progress`;
console.log(`Connecting to event source: ${sseUrl}`);
// Create the event source with the correct URL pattern
const source = new EventSource(
sseUrl,
{ withCredentials: true }
);
source.onopen = () => {
console.log(`SSE connection opened for ${type} progress`);
// Add a message indicating reconnection, but don't clear existing output
setScriptOutput(prev => [...prev, `[Connected to ${type} progress stream]`]);
};
source.onmessage = (event) => {
console.log(`SSE message received:`, event.data);
setScriptOutput((prev) => [...prev, event.data]);
try {
const data = JSON.parse(event.data);
// Handle completion events
if (data.status === 'complete' || data.status === 'error' || data.status === 'cancelled') {
if (data.operation === 'Full update complete' ||
data.operation === 'Full reset complete' ||
data.status === 'error' ||
data.status === 'cancelled') {
source.close();
setEventSource(null);
setIsUpdating(false);
setIsResetting(false);
fetchHistory(); // Refresh at end
if (data.status === 'complete') {
toast.success(`${type === 'update' ? 'Update' : 'Reset'} completed successfully`);
} else if (data.status === 'error') {
toast.error(`${type === 'update' ? 'Update' : 'Reset'} failed: ${data.error || 'Unknown error'}`);
} else {
toast.warning(`${type === 'update' ? 'Update' : 'Reset'} cancelled`);
}
}
}
// Also check progress objects that might be nested
if (data.progress && (data.progress.status === 'complete' || data.progress.status === 'error' || data.progress.status === 'cancelled')) {
if (data.progress.operation === 'Full update complete' ||
data.progress.operation === 'Full reset complete' ||
data.progress.status === 'error' ||
data.progress.status === 'cancelled') {
source.close();
setEventSource(null);
setIsUpdating(false);
setIsResetting(false);
fetchHistory(); // Refresh at end
if (data.progress.status === 'complete') {
toast.success(`${type === 'update' ? 'Update' : 'Reset'} completed successfully`);
} else if (data.progress.status === 'error') {
toast.error(`${type === 'update' ? 'Update' : 'Reset'} failed: ${data.progress.error || 'Unknown error'}`);
} else {
toast.warning(`${type === 'update' ? 'Update' : 'Reset'} cancelled`);
}
}
}
} catch (error) {
// Not JSON, just continue
}
};
source.onerror = (error) => {
console.error(`SSE connection error for ${type}:`, error);
// Attempt to reconnect with exponential backoff
if (source.readyState === EventSource.CLOSED) {
source.close();
setEventSource(null);
// Only attempt to reconnect if we're still updating/resetting
if ((type === "update" && isUpdating) || (type === "reset" && isResetting)) {
console.log("Connection closed, will attempt to reconnect...");
setTimeout(() => connectToEventSource(type), 3000);
}
}
};
setEventSource(source);
};
// Helper to format date // Helper to format date
const formatDate = (date: string) => { const formatDate = (date: string) => {
return new Date(date).toLocaleString(); return new Date(date).toLocaleString();
@@ -190,52 +403,9 @@ export function DataManagement() {
fetchHistory(); // Refresh at start fetchHistory(); // Refresh at start
try { try {
const source = new EventSource(`${config.apiUrl}/csv/update/progress`, { console.log("Starting full update...");
withCredentials: true, // Connect to the update SSE endpoint
}); connectToEventSource("update");
source.onmessage = (event) => {
setScriptOutput((prev) => [...prev, event.data]);
// Try to parse for status updates, but don't affect display
try {
const data = JSON.parse(event.data);
if (data.status === 'complete' || data.status === 'error' || data.status === 'cancelled') {
// Only close and reset state if this is the final completion message
if (data.operation === 'Full update complete' ||
data.status === 'error' ||
data.status === 'cancelled') {
source.close();
setEventSource(null);
setIsUpdating(false);
fetchHistory(); // Refresh at end
if (data.status === 'complete') {
toast.success("Update completed successfully");
} else if (data.status === 'error') {
toast.error(`Update failed: ${data.error || 'Unknown error'}`);
} else {
toast.warning("Update cancelled");
}
}
// For intermediate completions, just show a toast
else if (data.status === 'complete') {
toast.success(data.message || "Step completed");
}
}
} catch (error) {
// Not JSON, just display as is
}
};
source.onerror = (error) => {
setScriptOutput((prev) => [...prev, `[Error] ${error.type}`]);
source.close();
setEventSource(null);
setIsUpdating(false);
};
setEventSource(source);
const response = await fetch(`${config.apiUrl}/csv/full-update`, { const response = await fetch(`${config.apiUrl}/csv/full-update`, {
method: "POST", method: "POST",
@@ -246,12 +416,18 @@ export function DataManagement() {
const data = await response.json(); const data = await response.json();
throw new Error(data.error || "Failed to start update"); throw new Error(data.error || "Failed to start update");
} }
console.log("Full update request successful");
} catch (error) { } catch (error) {
console.error("Error starting full update:", error);
if (error instanceof Error) { if (error instanceof Error) {
toast.error(`Update failed: ${error.message}`); toast.error(`Update failed: ${error.message}`);
} }
setIsUpdating(false); setIsUpdating(false);
setEventSource(null); if (eventSource) {
eventSource.close();
setEventSource(null);
}
} }
}; };
@@ -261,52 +437,9 @@ export function DataManagement() {
fetchHistory(); // Refresh at start fetchHistory(); // Refresh at start
try { try {
const source = new EventSource(`${config.apiUrl}/csv/reset/progress`, { console.log("Starting full reset...");
withCredentials: true, // Connect to the reset SSE endpoint
}); connectToEventSource("reset");
source.onmessage = (event) => {
setScriptOutput((prev) => [...prev, event.data]);
// Try to parse for status updates, but don't affect display
try {
const data = JSON.parse(event.data);
if (data.status === 'complete' || data.status === 'error' || data.status === 'cancelled') {
// Only close and reset state if this is the final completion message
if (data.operation === 'Full reset complete' ||
data.status === 'error' ||
data.status === 'cancelled') {
source.close();
setEventSource(null);
setIsResetting(false);
fetchHistory(); // Refresh at end
if (data.status === 'complete') {
toast.success("Reset completed successfully");
} else if (data.status === 'error') {
toast.error(`Reset failed: ${data.error || 'Unknown error'}`);
} else {
toast.warning("Reset cancelled");
}
}
// For intermediate completions, just show a toast
else if (data.status === 'complete') {
toast.success(data.message || "Step completed");
}
}
} catch (error) {
// Not JSON, just display as is
}
};
source.onerror = (error) => {
setScriptOutput((prev) => [...prev, `[Error] ${error.type}`]);
source.close();
setEventSource(null);
setIsResetting(false);
};
setEventSource(source);
const response = await fetch(`${config.apiUrl}/csv/full-reset`, { const response = await fetch(`${config.apiUrl}/csv/full-reset`, {
method: "POST", method: "POST",
@@ -317,12 +450,18 @@ export function DataManagement() {
const data = await response.json(); const data = await response.json();
throw new Error(data.error || "Failed to start reset"); throw new Error(data.error || "Failed to start reset");
} }
console.log("Full reset request successful");
} catch (error) { } catch (error) {
console.error("Error starting full reset:", error);
if (error instanceof Error) { if (error instanceof Error) {
toast.error(`Reset failed: ${error.message}`); toast.error(`Reset failed: ${error.message}`);
} }
setIsResetting(false); setIsResetting(false);
setEventSource(null); if (eventSource) {
eventSource.close();
setEventSource(null);
}
} }
}; };
@@ -351,6 +490,9 @@ export function DataManagement() {
setIsResetting(false); setIsResetting(false);
toast.info("Operation cancelled"); toast.info("Operation cancelled");
// Refresh history data
fetchHistory();
} catch (error) { } catch (error) {
toast.error( toast.error(
`Failed to cancel operation: ${ `Failed to cancel operation: ${
@@ -443,14 +585,141 @@ export function DataManagement() {
}; };
useEffect(() => { useEffect(() => {
// Fetch data immediately on component mount // Set up async function to run process checks and initial data load
fetchHistory(); const initComponent = async () => {
try {
console.log("Initializing DataManagement component...");
setIsLoading(true);
// First check for any active operations
await checkActiveProcess();
// Then fetch data (but only if we're not already in an active operation)
if (!isUpdating && !isResetting) {
await fetchHistory();
}
console.log("Component initialization complete");
} catch (error) {
console.error("Error during component initialization:", error);
// Ensure we fetch data even if checkActiveProcess fails
await fetchHistory();
} finally {
setIsLoading(false);
}
};
// Run the initialization
initComponent();
// Set up periodic refresh every minute // Set up periodic refresh every minute
const refreshInterval = setInterval(fetchHistory, 60000); const refreshInterval = setInterval(fetchHistory, 60000);
// Clean up interval and event sources on component unmount
return () => {
console.log("Cleaning up DataManagement component...");
clearInterval(refreshInterval);
// Close any open event sources
if (eventSource) {
console.log("Closing event source on unmount");
eventSource.close();
}
};
}, []);
// Clean up interval on component unmount // Add a dedicated useEffect for direct SSE monitoring that doesn't rely on server status
return () => clearInterval(refreshInterval); useEffect(() => {
// Listen for SSE messages directly, even if the server reports no active processes
const setupDirectSSEMonitoring = () => {
console.log("Setting up direct SSE monitoring...");
// Try both update and reset endpoints
const setupListenerForType = (type: "update" | "reset") => {
const sseUrl = `${config.apiUrl}/csv/${type}/progress`;
console.log(`Setting up SSE listener for ${type} at ${sseUrl}`);
try {
const source = new EventSource(sseUrl, { withCredentials: true });
source.onopen = () => {
console.log(`Direct SSE connection opened for ${type}`);
};
source.onmessage = (event) => {
console.log(`Direct SSE message from ${type}:`, event.data);
try {
const data = JSON.parse(event.data);
// If we get a real progress message, update our state accordingly
if (data.progress && data.progress.status === 'running') {
console.log(`Detected running ${type} process from direct SSE`);
// Update the appropriate state flag
if (type === 'update') {
setIsUpdating(true);
setIsResetting(false);
} else {
setIsUpdating(false);
setIsResetting(true);
}
// Add the message to our output
setScriptOutput(prev => {
// Add as first message if no messages, otherwise append
if (prev.length === 0) {
return [`[Connected to running ${type} process]`, event.data];
} else {
return [...prev, event.data];
}
});
}
// Handle completion events
if (data.status === 'complete' || data.status === 'error' || data.status === 'cancelled' ||
(data.progress && (data.progress.status === 'complete' || data.progress.status === 'error' || data.progress.status === 'cancelled'))) {
console.log(`Process ${type} completed or failed`);
source.close();
setIsUpdating(false);
setIsResetting(false);
fetchHistory();
}
} catch (err) {
// Not JSON or invalid, just continue
}
};
source.onerror = (error) => {
console.log(`Direct SSE error for ${type}:`, error);
source.close();
};
// Return the source for cleanup
return source;
} catch (err) {
console.error(`Error setting up direct SSE for ${type}:`, err);
return null;
}
};
// Set up listeners for both types
const updateSource = setupListenerForType('update');
const resetSource = setupListenerForType('reset');
// Return cleanup function
return () => {
console.log("Cleaning up direct SSE monitoring");
if (updateSource) updateSource.close();
if (resetSource) resetSource.close();
};
};
// Set up monitoring
const cleanup = setupDirectSSEMonitoring();
// Cleanup on unmount
return cleanup;
}, []); }, []);
// Add useEffect to handle auto-scrolling // Add useEffect to handle auto-scrolling
@@ -460,11 +729,13 @@ export function DataManagement() {
} }
}, [scriptOutput]); }, [scriptOutput]);
// Replace renderTerminal with new version // Replace renderTerminal with new version - simple direct output display
const renderTerminal = () => { const renderTerminal = () => {
if (!scriptOutput.length) return null; if (!isUpdating && !isResetting && scriptOutput.length === 0) {
return null;
}
return ( return (
<Card className="col-span-2"> <Card className="col-span-2">
<CardHeader> <CardHeader>
<CardTitle>Script Output</CardTitle> <CardTitle>Script Output</CardTitle>
@@ -496,10 +767,19 @@ export function DataManagement() {
return new Intl.NumberFormat().format(num); return new Intl.NumberFormat().format(num);
}; };
// Update renderTableCountsSection to match other cards' styling // Update renderTableCountsSection to use skeletons
const renderTableCountsSection = () => { const renderTableCountsSection = () => {
if (!tableCounts) return null; const renderTableCountsSkeleton = () => (
<div>
{Array.from({ length: 18 }).map((_, i) => (
<div key={i} className="flex justify-between text-sm items-center py-2 border-b last:border-0">
<Skeleton className="h-4 w-[120px]" />
<Skeleton className="h-4 w-[60px]" />
</div>
))}
</div>
);
const renderTableGroup = (_title: string, tables: TableCount[]) => ( const renderTableGroup = (_title: string, tables: TableCount[]) => (
<div className="mt-0 border-t first:border-t-0 first:mt-0"> <div className="mt-0 border-t first:border-t-0 first:mt-0">
<div> <div>
@@ -526,14 +806,21 @@ export function DataManagement() {
<CardTitle>Table Record Counts</CardTitle> <CardTitle>Table Record Counts</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading && !tableCounts.core.length ? ( {isLoading ? (
<div className="flex justify-center py-4"> <div className="px-2">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" /> {renderTableCountsSkeleton()}
</div> </div>
) : !tableCounts ? (
<StatusEmptyState
message={hasError
? "Failed to load table data. Please try refreshing."
: "No table data available yet. Run a full update or reset."
}
/>
) : ( ) : (
<div> <div>
<div className="bg-sky-50/50 rounded-t-md px-2">{renderTableGroup('Core Tables', tableCounts.core)}</div> <div className="bg-sky-50/50 rounded-t-md px-2">{renderTableGroup('Core Tables', tableCounts?.core || [])}</div>
<div className="bg-green-50/50 rounded-b-md px-2">{renderTableGroup('Metrics Tables', tableCounts.metrics)}</div> <div className="bg-green-50/50 rounded-b-md px-2">{renderTableGroup('Metrics Tables', tableCounts?.metrics || [])}</div>
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -545,91 +832,91 @@ export function DataManagement() {
<div className="space-y-8 max-w-4xl"> <div className="space-y-8 max-w-4xl">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{/* Full Update Card */} {/* Full Update Card */}
<Card className="relative"> <Card className="relative">
<CardHeader className="pb-12"> <CardHeader className="pb-12">
<CardTitle>Full Update</CardTitle> <CardTitle>Full Update</CardTitle>
<CardDescription> <CardDescription>
Import latest data and recalculate all metrics Import latest data and recalculate all metrics
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
className="absolute bottom-4 right-[50%] translate-x-[50%] w-3/4" className="absolute bottom-4 right-[50%] translate-x-[50%] w-3/4"
onClick={handleFullUpdate} onClick={handleFullUpdate}
disabled={isUpdating || isResetting} disabled={isUpdating || isResetting}
> >
{isUpdating ? ( {isUpdating ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating... Updating...
</> </>
) : ( ) : (
<> <>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Run Full Update Run Full Update
</> </>
)} )}
</Button> </Button>
{isUpdating && ( {isUpdating && (
<Button variant="destructive" onClick={handleCancel} className="absolute top-4 right-4"> <Button variant="destructive" onClick={handleCancel} className="absolute top-4 right-4">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Full Reset Card */} {/* Full Reset Card */}
<Card className="relative"> <Card className="relative">
<CardHeader className="pb-12"> <CardHeader className="pb-12">
<CardTitle>Full Reset</CardTitle> <CardTitle>Full Reset</CardTitle>
<CardDescription> <CardDescription>
Reset database, reimport all data, and recalculate metrics Reset database, reimport all data, and recalculate metrics
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex gap-2"> <div className="flex gap-2">
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
variant="destructive" variant="destructive"
className="absolute bottom-4 right-[50%] translate-x-[50%] w-3/4" className="absolute bottom-4 right-[50%] translate-x-[50%] w-3/4"
disabled={isUpdating || isResetting} disabled={isUpdating || isResetting}
> >
{isResetting ? ( {isResetting ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Resetting... Resetting...
</> </>
) : ( ) : (
<> <>
<AlertTriangle className="mr-2 h-4 w-4" /> <AlertTriangle className="mr-2 h-4 w-4" />
Full Reset Full Reset
</> </>
)} )}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
Are you absolutely sure? Are you absolutely sure?
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will completely reset the database, delete all data, This will completely reset the database, delete all data,
and reimport everything from scratch. This action cannot and reimport everything from scratch. This action cannot
be undone. be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleFullReset}> <AlertDialogAction onClick={handleFullReset}>
Continue Continue
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{isResetting && ( {isResetting && (
<Button variant="destructive" onClick={handleCancel} className="absolute top-4 right-4"> <Button variant="destructive" onClick={handleCancel} className="absolute top-4 right-4">
@@ -641,8 +928,8 @@ export function DataManagement() {
</Card> </Card>
</div> </div>
{/* Terminal Output */} {/* Terminal Output - Always show if there are operations running */}
{(isUpdating || isResetting) && renderTerminal()} {renderTerminal()}
{/* History Section */} {/* History Section */}
<div className="space-y-4"> <div className="space-y-4">
@@ -673,8 +960,13 @@ export function DataManagement() {
<CardContent className="h-[calc(50%)]"> <CardContent className="h-[calc(50%)]">
<div className=""> <div className="">
{isLoading && !tableStatus.length ? ( {isLoading && !tableStatus.length ? (
<div className="flex justify-center py-4"> <div>
<Loader2 className="h-6 w-6 animate-spin text-gray-400" /> {Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex justify-between text-sm items-center py-2 border-b last:border-0">
<Skeleton className="h-4 w-[120px]" />
<Skeleton className="h-4 w-[60px]" />
</div>
))}
</div> </div>
) : tableStatus.length > 0 ? ( ) : tableStatus.length > 0 ? (
tableStatus.map((table) => ( tableStatus.map((table) => (
@@ -689,13 +981,14 @@ export function DataManagement() {
</div> </div>
)) ))
) : ( ) : (
<div className="text-sm text-muted-foreground py-4 text-center"> <StatusEmptyState
{hasError ? ( message={hasError
"Failed to load data. Please try refreshing." ? "Failed to load data. Please try refreshing."
) : ( : (
<>No imports have been performed yet.<br/>Run a full update or reset to import data.</> <>No imports have been performed yet.<br/>Run a full update or reset to import data.</>
)} )
</div> }
/>
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -709,8 +1002,13 @@ export function DataManagement() {
<CardContent className="h-[calc(50%)]"> <CardContent className="h-[calc(50%)]">
<div className=""> <div className="">
{isLoading && !moduleStatus.length ? ( {isLoading && !moduleStatus.length ? (
<div className="flex justify-center py-4"> <div>
<Loader2 className="h-6 w-6 animate-spin text-gray-400" /> {Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="flex justify-between text-sm items-center py-2 border-b last:border-0">
<Skeleton className="h-4 w-[120px]" />
<Skeleton className="h-4 w-[60px]" />
</div>
))}
</div> </div>
) : moduleStatus.length > 0 ? ( ) : moduleStatus.length > 0 ? (
moduleStatus.map((module) => ( moduleStatus.map((module) => (
@@ -725,13 +1023,14 @@ export function DataManagement() {
</div> </div>
)) ))
) : ( ) : (
<div className="text-sm text-muted-foreground py-4 text-center"> <StatusEmptyState
{hasError ? ( message={hasError
"Failed to load data. Please try refreshing." ? "Failed to load data. Please try refreshing."
) : ( : (
<>No metrics have been calculated yet.<br/>Run a full update or reset to calculate metrics.</> <>No metrics have been calculated yet.<br/>Run a full update or reset to calculate metrics.</>
)} )
</div> }
/>
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -751,14 +1050,7 @@ export function DataManagement() {
<Table> <Table>
<TableBody> <TableBody>
{isLoading && !importHistory.length ? ( {isLoading && !importHistory.length ? (
<TableRow> <TableSkeleton rows={10} columns={4} useAccordion={true} />
<TableCell className="text-center py-8">
<div className="flex flex-col items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
<span className="text-sm text-muted-foreground">Loading import history...</span>
</div>
</TableCell>
</TableRow>
) : importHistory.length > 0 ? ( ) : importHistory.length > 0 ? (
importHistory.slice(0, 20).map((record) => ( importHistory.slice(0, 20).map((record) => (
<TableRow key={record.id} className="hover:bg-transparent"> <TableRow key={record.id} className="hover:bg-transparent">
@@ -838,15 +1130,12 @@ export function DataManagement() {
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> <EmptyStateSkeleton
<TableCell className="text-center text-sm text-muted-foreground py-4"> message={hasError
{hasError ? ( ? "Failed to load import history. Please try refreshing."
"Failed to load import history. Please try refreshing." : "No import history available"
) : ( }
"No import history available" />
)}
</TableCell>
</TableRow>
)} )}
</TableBody> </TableBody>
</Table> </Table>
@@ -862,14 +1151,7 @@ export function DataManagement() {
<Table> <Table>
<TableBody> <TableBody>
{isLoading && !calculateHistory.length ? ( {isLoading && !calculateHistory.length ? (
<TableRow> <TableSkeleton rows={9} columns={4} useAccordion={true} />
<TableCell className="text-center py-8">
<div className="flex flex-col items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
<span className="text-sm text-muted-foreground">Loading calculation history...</span>
</div>
</TableCell>
</TableRow>
) : calculateHistory.length > 0 ? ( ) : calculateHistory.length > 0 ? (
calculateHistory.slice(0, 20).map((record) => ( calculateHistory.slice(0, 20).map((record) => (
<TableRow key={record.id} className="hover:bg-transparent"> <TableRow key={record.id} className="hover:bg-transparent">
@@ -954,15 +1236,12 @@ export function DataManagement() {
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> <EmptyStateSkeleton
<TableCell className="text-center text-sm text-muted-foreground py-4"> message={hasError
{hasError ? ( ? "Failed to load calculation history. Please try refreshing."
"Failed to load calculation history. Please try refreshing." : "No calculation history available"
) : ( }
"No calculation history available" />
)}
</TableCell>
</TableRow>
)} )}
</TableBody> </TableBody>
</Table> </Table>

View File

@@ -96,17 +96,15 @@
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground font-sans;
} }
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground font-sans;
} }
} }

View File

@@ -1,6 +1,7 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import './App.css'
import App from './App.tsx' import App from './App.tsx'
import { BrowserRouter as Router } from 'react-router-dom' import { BrowserRouter as Router } from 'react-router-dom'

View File

@@ -1,28 +1,31 @@
import { useState, useContext } from "react"; import { useState, useContext } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { AuthContext } from "@/contexts/AuthContext";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { toast } from "sonner"; import { Label } from "@/components/ui/label";
import { Loader2, Box } from "lucide-react"; import { motion } from "motion/react";
import { motion } from "framer-motion";
import { AuthContext } from "@/contexts/AuthContext";
export function Login() { export function Login() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { login } = useContext(AuthContext); const { login } = useContext(AuthContext);
const handleLogin = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
const formData = new FormData(e.currentTarget);
const username = formData.get("username") as string;
const password = formData.get("password") as string;
try { try {
await login(username, password); await login(username, password);
// Login successful, redirect to the requested page or home // Login successful, redirect to the requested page or home
const redirectTo = searchParams.get("redirect") || "/"; const redirectTo = searchParams.get("redirect") || "/";
navigate(redirectTo); navigate(redirectTo);
@@ -36,70 +39,77 @@ export function Login() {
}; };
return ( return (
<motion.div <motion.div className="flex min-h-svh flex-row items-center justify-center bg-muted p-6 md:p-10">
layout <div className="fixed top-0 w-full backdrop-blur-sm bg-white/40 border-b shadow-sm z-10">
className="min-h-screen bg-gradient-to-b from-slate-100 to-slate-200 dark:from-slate-900 dark:to-slate-800 antialiased" <div className="mx-auto p-4 sm:p-6">
> <div className="flex items-center gap-2 font-medium text-3xl justify-center sm:justify-start">
<div className="flex flex-col gap-2 p-2 bg-primary"> <div className="relative">
<div className="p-4 flex items-center gap-2 group-data-[collapsible=icon]:justify-center text-white"> <div className="absolute inset-0 "></div>
<Box className="h-6 w-6 shrink-0" /> <img
<h2 className="text-lg font-semibold group-data-[collapsible=icon]:hidden"> src="/cherrybottom.png"
Inventory Manager alt="Cherry Bottom"
</h2> className="h-12 w-12 object-contain -rotate-12 transform hover:rotate-0 transition-transform ease-in-out duration-300 relative z-10"
/>
</div>
<span className="font-bold font-text-primary">A Cherry On Bottom</span>
</div>
<p className="text-sm italic text-muted-foreground text-center sm:text-left ml-32 -mt-1">
supporting the cherry on top
</p>
</div> </div>
</div> </div>
<motion.div <div className="w-full sm:w-[80%] max-w-sm mt-20">
initial={{ opacity: 0, scale: 0.95 }} <LoginForm onSubmit={handleSubmit} isLoading={isLoading} />
animate={{ opacity: 1, scale: 1 }} </div>
transition={{ duration: 0.3, delay: 0.2 }}
className="container relative flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center" </motion.div>
> );
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]"> }
<Card className="border-none shadow-xl">
<CardHeader className="space-y-1"> interface LoginFormProps {
<div className="flex items-center justify-center mb-2"> className?: string;
<Box className="h-10 w-10 text-primary" /> isLoading?: boolean;
</div> onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
<CardTitle className="text-2xl text-center"> }
Log in to continue
</CardTitle> function LoginForm({ className, isLoading, onSubmit, ...props }: LoginFormProps) {
</CardHeader> return (
<CardContent> <motion.div className={cn("flex flex-col gap-6", className)} {...props}>
<form onSubmit={handleLogin}> <Card className="overflow-hidden rounded-lg shadow-lg">
<div className="grid gap-4"> <CardHeader className="pb-0">
<div className="grid gap-2"> <CardTitle className="text-2xl font-bold text-center">Log in to your account</CardTitle>
<Input </CardHeader>
id="username" <CardContent className="grid p-0 h-full">
placeholder="Username" <form className="p-6 md:p-8 flex flex-col gap-6" onSubmit={onSubmit}>
value={username} <div className="grid gap-2">
onChange={(e) => setUsername(e.target.value)} <Label htmlFor="username">Username</Label>
disabled={isLoading} <Input
className="w-full" id="username"
/> name="username"
</div> type="text"
<div className="grid gap-2"> required
<Input disabled={isLoading}
id="password" />
type="password" </div>
placeholder="Password"
value={password} <div className="grid gap-2">
onChange={(e) => setPassword(e.target.value)} <Label htmlFor="password">Password</Label>
disabled={isLoading} <Input
className="w-full" id="password"
/> name="password"
</div> type="password"
<Button className="w-full" type="submit" disabled={isLoading}> required
{isLoading && ( disabled={isLoading}
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> />
)} </div>
Sign In
</Button> <Button type="submit" className="w-full" disabled={isLoading}>
</div> {isLoading ? "Logging in..." : "Log In"}
</form> </Button>
</CardContent> </form>
</Card>
</div> </CardContent>
</motion.div> </Card>
</motion.div> </motion.div>
); );
} }

View File

@@ -14,6 +14,9 @@ export default {
} }
}, },
extend: { extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
colors: { colors: {
border: 'hsl(var(--border))', border: 'hsl(var(--border))',
input: 'hsl(var(--input))', input: 'hsl(var(--input))',