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">
<head>
<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" />
<title>Inventory Manager</title>
<title>A Cherry On Bottom</title>
</head>
<body>
<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 {
max-width: 1800px;
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;
font-family: 'Inter', sans-serif;
}

View File

@@ -3,7 +3,6 @@ import {
Package,
BarChart2,
Settings,
Box,
ClipboardList,
LogOut,
Users,
@@ -22,6 +21,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
useSidebar
} from "@/components/ui/sidebar";
import { useLocation, useNavigate, Link } from "react-router-dom";
import { Protected } from "@/components/auth/Protected";
@@ -80,6 +80,7 @@ const items = [
export function AppSidebar() {
const location = useLocation();
const navigate = useNavigate();
useSidebar();
const handleLogout = () => {
localStorage.removeItem('token');
@@ -90,11 +91,19 @@ export function AppSidebar() {
return (
<Sidebar collapsible="icon" variant="sidebar">
<SidebarHeader>
<div className="p-4 flex items-center gap-2 group-data-[collapsible=icon]:justify-center">
<Box className="h-6 w-6 shrink-0" />
<h2 className="text-lg font-semibold group-data-[collapsible=icon]:hidden">
Inventory Manager
</h2>
<div className="py-1 flex justify-center items-center">
<div className="flex items-center">
<div className="flex-shrink-0 w-8 h-8 relative flex items-center justify-center">
<img
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>
</SidebarHeader>
<SidebarSeparator />

View File

@@ -28,6 +28,7 @@ import { Loader2, X, RefreshCw, AlertTriangle, RefreshCcw, Hourglass } from "luc
import config from "../../config";
import { toast } from "sonner";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
interface HistoryRecord {
@@ -78,6 +79,75 @@ interface GroupedTableCounts {
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() {
const [isUpdating, setIsUpdating] = useState(false);
const [isResetting, setIsResetting] = useState(false);
@@ -94,6 +164,149 @@ export function DataManagement() {
// Add useRef for scroll handling
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
const formatDate = (date: string) => {
return new Date(date).toLocaleString();
@@ -190,52 +403,9 @@ export function DataManagement() {
fetchHistory(); // Refresh at start
try {
const source = new EventSource(`${config.apiUrl}/csv/update/progress`, {
withCredentials: true,
});
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);
console.log("Starting full update...");
// Connect to the update SSE endpoint
connectToEventSource("update");
const response = await fetch(`${config.apiUrl}/csv/full-update`, {
method: "POST",
@@ -246,12 +416,18 @@ export function DataManagement() {
const data = await response.json();
throw new Error(data.error || "Failed to start update");
}
console.log("Full update request successful");
} catch (error) {
console.error("Error starting full update:", error);
if (error instanceof Error) {
toast.error(`Update failed: ${error.message}`);
}
setIsUpdating(false);
setEventSource(null);
if (eventSource) {
eventSource.close();
setEventSource(null);
}
}
};
@@ -261,52 +437,9 @@ export function DataManagement() {
fetchHistory(); // Refresh at start
try {
const source = new EventSource(`${config.apiUrl}/csv/reset/progress`, {
withCredentials: true,
});
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);
console.log("Starting full reset...");
// Connect to the reset SSE endpoint
connectToEventSource("reset");
const response = await fetch(`${config.apiUrl}/csv/full-reset`, {
method: "POST",
@@ -317,12 +450,18 @@ export function DataManagement() {
const data = await response.json();
throw new Error(data.error || "Failed to start reset");
}
console.log("Full reset request successful");
} catch (error) {
console.error("Error starting full reset:", error);
if (error instanceof Error) {
toast.error(`Reset failed: ${error.message}`);
}
setIsResetting(false);
setEventSource(null);
if (eventSource) {
eventSource.close();
setEventSource(null);
}
}
};
@@ -351,6 +490,9 @@ export function DataManagement() {
setIsResetting(false);
toast.info("Operation cancelled");
// Refresh history data
fetchHistory();
} catch (error) {
toast.error(
`Failed to cancel operation: ${
@@ -443,14 +585,141 @@ export function DataManagement() {
};
useEffect(() => {
// Fetch data immediately on component mount
fetchHistory();
// Set up async function to run process checks and initial data load
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
const refreshInterval = setInterval(fetchHistory, 60000);
// Clean up interval on component unmount
return () => clearInterval(refreshInterval);
// 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();
}
};
}, []);
// Add a dedicated useEffect for direct SSE monitoring that doesn't rely on server status
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
@@ -460,11 +729,13 @@ export function DataManagement() {
}
}, [scriptOutput]);
// Replace renderTerminal with new version
// Replace renderTerminal with new version - simple direct output display
const renderTerminal = () => {
if (!scriptOutput.length) return null;
if (!isUpdating && !isResetting && scriptOutput.length === 0) {
return null;
}
return (
return (
<Card className="col-span-2">
<CardHeader>
<CardTitle>Script Output</CardTitle>
@@ -496,9 +767,18 @@ export function DataManagement() {
return new Intl.NumberFormat().format(num);
};
// Update renderTableCountsSection to match other cards' styling
// Update renderTableCountsSection to use skeletons
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[]) => (
<div className="mt-0 border-t first:border-t-0 first:mt-0">
@@ -526,14 +806,21 @@ export function DataManagement() {
<CardTitle>Table Record Counts</CardTitle>
</CardHeader>
<CardContent>
{isLoading && !tableCounts.core.length ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
{isLoading ? (
<div className="px-2">
{renderTableCountsSkeleton()}
</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 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-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>
)}
</CardContent>
@@ -545,91 +832,91 @@ export function DataManagement() {
<div className="space-y-8 max-w-4xl">
<div className="grid gap-4 md:grid-cols-2">
{/* Full Update Card */}
<Card className="relative">
<CardHeader className="pb-12">
<Card className="relative">
<CardHeader className="pb-12">
<CardTitle>Full Update</CardTitle>
<CardDescription>
Import latest data and recalculate all metrics
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button
className="absolute bottom-4 right-[50%] translate-x-[50%] w-3/4"
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button
className="absolute bottom-4 right-[50%] translate-x-[50%] w-3/4"
onClick={handleFullUpdate}
disabled={isUpdating || isResetting}
>
>
{isUpdating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Run Full Update
</>
)}
</Button>
</>
)}
</Button>
{isUpdating && (
<Button variant="destructive" onClick={handleCancel} className="absolute top-4 right-4">
<X className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
<X className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
{/* Full Reset Card */}
<Card className="relative">
<CardHeader className="pb-12">
<Card className="relative">
<CardHeader className="pb-12">
<CardTitle>Full Reset</CardTitle>
<CardDescription>
Reset database, reimport all data, and recalculate metrics
</CardDescription>
</CardHeader>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
className="absolute bottom-4 right-[50%] translate-x-[50%] w-3/4"
disabled={isUpdating || isResetting}
>
{isResetting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
>
{isResetting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Resetting...
</>
) : (
</>
) : (
<>
<AlertTriangle className="mr-2 h-4 w-4" />
Full Reset
</>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription>
<AlertDialogDescription>
This will completely reset the database, delete all data,
and reimport everything from scratch. This action cannot
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleFullReset}>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{isResetting && (
<Button variant="destructive" onClick={handleCancel} className="absolute top-4 right-4">
@@ -641,8 +928,8 @@ export function DataManagement() {
</Card>
</div>
{/* Terminal Output */}
{(isUpdating || isResetting) && renderTerminal()}
{/* Terminal Output - Always show if there are operations running */}
{renderTerminal()}
{/* History Section */}
<div className="space-y-4">
@@ -673,8 +960,13 @@ export function DataManagement() {
<CardContent className="h-[calc(50%)]">
<div className="">
{isLoading && !tableStatus.length ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
<div>
{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>
) : tableStatus.length > 0 ? (
tableStatus.map((table) => (
@@ -689,13 +981,14 @@ export function DataManagement() {
</div>
))
) : (
<div className="text-sm text-muted-foreground py-4 text-center">
{hasError ? (
"Failed to load data. Please try refreshing."
) : (
<>No imports have been performed yet.<br/>Run a full update or reset to import data.</>
)}
</div>
<StatusEmptyState
message={hasError
? "Failed to load data. Please try refreshing."
: (
<>No imports have been performed yet.<br/>Run a full update or reset to import data.</>
)
}
/>
)}
</div>
</CardContent>
@@ -709,8 +1002,13 @@ export function DataManagement() {
<CardContent className="h-[calc(50%)]">
<div className="">
{isLoading && !moduleStatus.length ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
<div>
{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>
) : moduleStatus.length > 0 ? (
moduleStatus.map((module) => (
@@ -725,13 +1023,14 @@ export function DataManagement() {
</div>
))
) : (
<div className="text-sm text-muted-foreground py-4 text-center">
{hasError ? (
"Failed to load data. Please try refreshing."
) : (
<>No metrics have been calculated yet.<br/>Run a full update or reset to calculate metrics.</>
)}
</div>
<StatusEmptyState
message={hasError
? "Failed to load data. Please try refreshing."
: (
<>No metrics have been calculated yet.<br/>Run a full update or reset to calculate metrics.</>
)
}
/>
)}
</div>
</CardContent>
@@ -751,14 +1050,7 @@ export function DataManagement() {
<Table>
<TableBody>
{isLoading && !importHistory.length ? (
<TableRow>
<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>
<TableSkeleton rows={10} columns={4} useAccordion={true} />
) : importHistory.length > 0 ? (
importHistory.slice(0, 20).map((record) => (
<TableRow key={record.id} className="hover:bg-transparent">
@@ -838,15 +1130,12 @@ export function DataManagement() {
</TableRow>
))
) : (
<TableRow>
<TableCell className="text-center text-sm text-muted-foreground py-4">
{hasError ? (
"Failed to load import history. Please try refreshing."
) : (
"No import history available"
)}
</TableCell>
</TableRow>
<EmptyStateSkeleton
message={hasError
? "Failed to load import history. Please try refreshing."
: "No import history available"
}
/>
)}
</TableBody>
</Table>
@@ -862,14 +1151,7 @@ export function DataManagement() {
<Table>
<TableBody>
{isLoading && !calculateHistory.length ? (
<TableRow>
<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>
<TableSkeleton rows={9} columns={4} useAccordion={true} />
) : calculateHistory.length > 0 ? (
calculateHistory.slice(0, 20).map((record) => (
<TableRow key={record.id} className="hover:bg-transparent">
@@ -954,15 +1236,12 @@ export function DataManagement() {
</TableRow>
))
) : (
<TableRow>
<TableCell className="text-center text-sm text-muted-foreground py-4">
{hasError ? (
"Failed to load calculation history. Please try refreshing."
) : (
"No calculation history available"
)}
</TableCell>
</TableRow>
<EmptyStateSkeleton
message={hasError
? "Failed to load calculation history. Please try refreshing."
: "No calculation history available"
}
/>
)}
</TableBody>
</Table>

View File

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

View File

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

View File

@@ -1,25 +1,28 @@
import { useState, useContext } from "react";
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { Loader2, Box } from "lucide-react";
import { motion } from "framer-motion";
import { AuthContext } from "@/contexts/AuthContext";
import { Label } from "@/components/ui/label";
import { motion } from "motion/react";
export function Login() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { login } = useContext(AuthContext);
const handleLogin = async (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
const formData = new FormData(e.currentTarget);
const username = formData.get("username") as string;
const password = formData.get("password") as string;
try {
await login(username, password);
@@ -36,70 +39,77 @@ export function Login() {
};
return (
<motion.div
layout
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="flex flex-col gap-2 p-2 bg-primary">
<div className="p-4 flex items-center gap-2 group-data-[collapsible=icon]:justify-center text-white">
<Box className="h-6 w-6 shrink-0" />
<h2 className="text-lg font-semibold group-data-[collapsible=icon]:hidden">
Inventory Manager
</h2>
<motion.div className="flex min-h-svh flex-row items-center justify-center bg-muted p-6 md:p-10">
<div className="fixed top-0 w-full backdrop-blur-sm bg-white/40 border-b shadow-sm z-10">
<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="relative">
<div className="absolute inset-0 "></div>
<img
src="/cherrybottom.png"
alt="Cherry Bottom"
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>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: 0.2 }}
className="container relative flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center"
>
<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">
<div className="flex items-center justify-center mb-2">
<Box className="h-10 w-10 text-primary" />
</div>
<CardTitle className="text-2xl text-center">
Log in to continue
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin}>
<div className="grid gap-4">
<div className="grid gap-2">
<Input
id="username"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
className="w-full"
/>
</div>
<div className="grid gap-2">
<Input
id="password"
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="w-full"
/>
</div>
<Button className="w-full" type="submit" disabled={isLoading}>
{isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Sign In
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</motion.div>
<div className="w-full sm:w-[80%] max-w-sm mt-20">
<LoginForm onSubmit={handleSubmit} isLoading={isLoading} />
</div>
</motion.div>
);
}
interface LoginFormProps {
className?: string;
isLoading?: boolean;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
}
function LoginForm({ className, isLoading, onSubmit, ...props }: LoginFormProps) {
return (
<motion.div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden rounded-lg shadow-lg">
<CardHeader className="pb-0">
<CardTitle className="text-2xl font-bold text-center">Log in to your account</CardTitle>
</CardHeader>
<CardContent className="grid p-0 h-full">
<form className="p-6 md:p-8 flex flex-col gap-6" onSubmit={onSubmit}>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
type="text"
required
disabled={isLoading}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Logging in..." : "Log In"}
</Button>
</form>
</CardContent>
</Card>
</motion.div>
);
}

View File

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