Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ToastProvider } from '@/components/ui/toast';
import { CommandPalette } from '@/components/ui/command-palette';
import { useCommandPalette } from '@/hooks/useCommandPalette';
import { ThemeProvider } from '@/lib/theme-provider';
import { AuthInitializer } from '@/components/auth-initializer';

const queryClient = new QueryClient({
defaultOptions: {
Expand All @@ -26,6 +27,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<AuthInitializer />
{children}
<ToastProvider />
<CommandPaletteInitializer />
Expand Down
24 changes: 22 additions & 2 deletions frontend/components/auth-initializer.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
'use client';

import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useAuthStore } from '@/store/auth.store';

export function AuthInitializer() {
const loadUser = useAuthStore((s) => s.loadUser);
const isLoading = useAuthStore((s) => s.isLoading);
const [initialized, setInitialized] = useState(false);

useEffect(() => {
loadUser();
let mounted = true;
loadUser().finally(() => {
if (mounted) setInitialized(true);
});
return () => {
mounted = false;
};
}, [loadUser]);

if (!initialized || isLoading) {
return (
<div className="fixed inset-0 z-[100] bg-white p-6 dark:bg-gray-950" aria-label="Loading">
<div className="mx-auto max-w-6xl animate-pulse space-y-6">
<div className="h-10 w-48 rounded bg-gray-200 dark:bg-gray-800" />
<div className="h-32 rounded-lg bg-gray-100 dark:bg-gray-900" />
<div className="h-64 rounded-lg bg-gray-100 dark:bg-gray-900" />
</div>
</div>
);
}

return null;
}
4 changes: 2 additions & 2 deletions frontend/components/layout/sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Sidebar } from './sidebar';

const mockPush = jest.fn();
Expand Down Expand Up @@ -46,7 +46,7 @@ describe('Sidebar', () => {
const logoutButton = screen.getByText('Logout');
fireEvent.click(logoutButton);
expect(mockLogout).toHaveBeenCalledTimes(1);
expect(mockPush).toHaveBeenCalledWith('/login');
await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/login'));
});

it('renders Settings and Notifications links', () => {
Expand Down
8 changes: 6 additions & 2 deletions frontend/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ const navItems = [
{ href: "/reports", label: "Reports", icon: BarChart3 },
];

export function isActiveRoute(pathname: string, href: string) {
return pathname === href || pathname.startsWith(`${href}/`);
}

interface SidebarProps {
open?: boolean;
onClose?: () => void;
Expand All @@ -45,7 +49,7 @@ interface SidebarProps {
export function Sidebar({ open, onClose }: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const { logout } = useAuthStore();
const logout = useAuthStore((state) => state.logout);
const asideRef = useRef<HTMLElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);

Expand Down Expand Up @@ -191,7 +195,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto" aria-label="Site pages">
{navItems.map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(`${href}/`);
const active = isActiveRoute(pathname, href);
return (
<Link
key={href}
Expand Down
39 changes: 24 additions & 15 deletions frontend/components/qr-scanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ export function QRScanner({ onScan, onClose }: QRScannerProps) {
const [mode, setMode] = useState<"camera" | "manual">("camera");
const [manualId, setManualId] = useState("");
const [error, setError] = useState("");
const [scanning, setScanning] = useState(false);

useEffect(() => {
if (mode !== "camera") return;
let stream: MediaStream | null = null;
let cancelled = false;

const startCamera = async () => {
if (!navigator.mediaDevices?.getUserMedia) {
Expand All @@ -37,12 +37,11 @@ export function QRScanner({ onScan, onClose }: QRScannerProps) {
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
setScanning(true);
// Try BarcodeDetector API
if ("BarcodeDetector" in window) {
const detector = new (window as any).BarcodeDetector({ formats: ["qr_code"] });
const detect = async () => {
if (!videoRef.current || !scanning) return;
if (!videoRef.current || cancelled) return;
try {
const barcodes = await detector.detect(videoRef.current);
if (barcodes.length > 0) {
Expand Down Expand Up @@ -72,7 +71,7 @@ export function QRScanner({ onScan, onClose }: QRScannerProps) {

startCamera();
return () => {
setScanning(false);
cancelled = true;
stream?.getTracks().forEach((t) => t.stop());
};
}, [mode, onScan]);
Expand Down Expand Up @@ -104,17 +103,27 @@ export function QRScanner({ onScan, onClose }: QRScannerProps) {
{error}
</div>
)}
<input
type="text"
placeholder="Enter Asset ID"
value={manualId}
onChange={(e) => setManualId(e.target.value)}
className="w-full px-4 py-3 rounded-lg text-lg text-center"
autoFocus
/>
<Button className="w-full" onClick={() => manualId && onScan(manualId)}>
Look Up Asset
</Button>
<form
onSubmit={(event) => {
event.preventDefault();
const assetId = manualId.trim();
if (assetId) onScan(assetId);
}}
className="space-y-4"
>
<input
type="text"
placeholder="Enter Asset ID"
aria-label="Asset ID"
value={manualId}
onChange={(e) => setManualId(e.target.value)}
className="w-full px-4 py-3 rounded-lg text-lg text-center"
autoFocus
/>
<Button className="w-full" type="submit" disabled={!manualId.trim()}>
Look Up Asset
</Button>
</form>
</div>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,45 @@
import React from "react";
import { DndContext, DragEndEvent, closestCenter, useDraggable, useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { useLocationsManagement } from "./useLocationsManagement";
import { Location } from "./LocationsManagementTypes";
export const LocationsManagementComponent: React.FC = () => {
const { locations } = useLocationsManagement();
const { locations, moveLocation, reorderLocation } = useLocationsManagement();
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
const activeLocation = locations.find((loc) => loc.id === active.id);
const targetLocation = locations.find((loc) => loc.id === over.id);
if (!activeLocation || !targetLocation) return;
if (activeLocation.parentLocationId === targetLocation.parentLocationId) {
reorderLocation(String(active.id), String(over.id));
} else {
moveLocation(String(active.id), String(over.id));
}
};
return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div className="p-4">
<h1 className="text-2xl font-bold">Locations Management</h1>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
{locations.map((loc: Location) => (
<div key={loc.id} className="border p-4 rounded shadow">
<h2 className="font-semibold">{loc.name}</h2>
<p className="text-gray-600">{loc.address}</p>
</div>
<DraggableLocation key={loc.id} location={loc} />
))}
</div>
</div>
</DndContext>
);
};

function DraggableLocation({ location }: { location: Location }) {
const { attributes, listeners, setNodeRef: setDragRef, transform } = useDraggable({ id: location.id });
const { setNodeRef: setDropRef, isOver } = useDroppable({ id: location.id });
const style = { transform: CSS.Translate.toString(transform) };

return (
<div ref={(node) => { setDragRef(node); setDropRef(node); }} style={style} {...listeners} {...attributes}
className={`border p-4 rounded shadow cursor-grab active:cursor-grabbing ${isOver ? "ring-2 ring-blue-500" : ""}`}>
<h2 className="font-semibold">{location.name}</h2>
<p className="text-gray-600">{location.address}</p>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export interface Location {
id: string;
name: string;
address: string;
parentLocationId?: string;
sortOrder?: number;
}
export interface LocationsState {
locations: Location[];
Expand Down
35 changes: 34 additions & 1 deletion frontend/features/LocationsManagement/useLocationsManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,38 @@ import { Location } from "./LocationsManagementTypes";
export const useLocationsManagement = () => {
const [locations, setLocations] = useState<Location[]>([]);
const addLocation = (loc: Location) => setLocations((prev) => [...prev, loc]);
return { locations, addLocation };
const moveLocation = (locationId: string, targetId: string) => {
setLocations((prev) => {
const location = prev.find((item) => item.id === locationId);
const target = prev.find((item) => item.id === targetId);
if (!location || !target || locationId === targetId) return prev;

const descendants = new Set<string>();
let parentId = target.parentLocationId;
while (parentId) {
descendants.add(parentId);
parentId = prev.find((item) => item.id === parentId)?.parentLocationId;
}
if (descendants.has(locationId)) return prev;

const next = prev.map((item) =>
item.id === locationId
? { ...item, parentLocationId: targetId, sortOrder: target.sortOrder ?? 0 }
: item,
);
return next.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
});
};
const reorderLocation = (locationId: string, targetId: string) => {
setLocations((prev) => {
const from = prev.findIndex((item) => item.id === locationId);
const to = prev.findIndex((item) => item.id === targetId);
if (from < 0 || to < 0 || from === to) return prev;
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next.map((item, index) => ({ ...item, sortOrder: index }));
});
};
return { locations, addLocation, moveLocation, reorderLocation };
};
7 changes: 5 additions & 2 deletions frontend/store/auth.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,12 @@ export const useAuthStore = create<AuthState>((set) => ({
},

loadUser: async () => {
const token = localStorage.getItem('accessToken');
if (!token) return;
set({ isLoading: true });
const token = localStorage.getItem('accessToken');
if (!token) {
set({ isLoading: false });
return;
}
try {
const user = await authApi.me();
set({ user, isAuthenticated: true });
Expand Down