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,13 +416,19 @@ 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);
if (eventSource) {
eventSource.close();
setEventSource(null); setEventSource(null);
} }
}
}; };
const handleFullReset = async () => { const handleFullReset = async () => {
@@ -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,13 +450,19 @@ 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);
if (eventSource) {
eventSource.close();
setEventSource(null); setEventSource(null);
} }
}
}; };
const handleCancel = async () => { const handleCancel = async () => {
@@ -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 on component unmount // Clean up interval and event sources on component unmount
return () => clearInterval(refreshInterval); 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 // Add useEffect to handle auto-scrolling
@@ -460,9 +729,11 @@ 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">
@@ -496,9 +767,18 @@ 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">
@@ -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>
@@ -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,25 +1,28 @@
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);
@@ -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>
</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>
<div className="grid gap-2"> <span className="font-bold font-text-primary">A Cherry On Bottom</span>
<Input
id="password"
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="w-full"
/>
</div> </div>
<Button className="w-full" type="submit" disabled={isLoading}> <p className="text-sm italic text-muted-foreground text-center sm:text-left ml-32 -mt-1">
{isLoading && ( supporting the cherry on top
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> </p>
)}
Sign In
</Button>
</div> </div>
</form>
</CardContent>
</Card>
</div> </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> </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))',