creates application

This commit is contained in:
simon.franken
2026-02-16 10:15:27 +01:00
parent 791c661395
commit 7d678c1c4d
65 changed files with 10389 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
import { Outlet } from 'react-router-dom';
import { Navbar } from './Navbar';
import { TimerWidget } from './TimerWidget';
export function Layout() {
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="pt-4 pb-24 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<Outlet />
</div>
</main>
<TimerWidget />
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { NavLink } from 'react-router-dom';
import { Clock, List, Briefcase, FolderOpen, LogOut } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
export function Navbar() {
const { user, logout } = useAuth();
const navItems = [
{ to: '/dashboard', label: 'Dashboard', icon: Clock },
{ to: '/time-entries', label: 'Time Entries', icon: List },
{ to: '/clients', label: 'Clients', icon: Briefcase },
{ to: '/projects', label: 'Projects', icon: FolderOpen },
];
return (
<nav className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<Clock className="h-8 w-8 text-primary-600" />
<span className="ml-2 text-xl font-bold text-gray-900">TimeTracker</span>
</div>
<div className="hidden sm:ml-8 sm:flex sm:space-x-4">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${
isActive
? 'text-primary-600 bg-primary-50'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
}`
}
>
<item.icon className="h-4 w-4 mr-2" />
{item.label}
</NavLink>
))}
</div>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-600 hidden sm:block">
{user?.username}
</span>
<button
onClick={logout}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<LogOut className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Logout</span>
</button>
</div>
</div>
</div>
{/* Mobile navigation */}
<div className="sm:hidden border-t border-gray-200">
<div className="flex justify-around py-2">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex flex-col items-center p-2 text-xs font-medium rounded-md ${
isActive ? 'text-primary-600' : 'text-gray-600'
}`
}
>
<item.icon className="h-5 w-5 mb-1" />
{item.label}
</NavLink>
))}
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,24 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,163 @@
import { useState } from 'react';
import { Play, Square, ChevronDown } from 'lucide-react';
import { useTimer } from '@/contexts/TimerContext';
import { useProjects } from '@/hooks/useProjects';
import { formatDuration } from '@/utils/dateUtils';
export function TimerWidget() {
const { ongoingTimer, isLoading, elapsedSeconds, startTimer, stopTimer, updateTimerProject } = useTimer();
const { projects } = useProjects();
const [showProjectSelect, setShowProjectSelect] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleStart = async () => {
setError(null);
try {
await startTimer();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start timer');
}
};
const handleStop = async () => {
setError(null);
try {
await stopTimer();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to stop timer');
}
};
const handleProjectChange = async (projectId: string) => {
setError(null);
try {
await updateTimerProject(projectId);
setShowProjectSelect(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update project');
}
};
const handleClearProject = async () => {
setError(null);
try {
await updateTimerProject(null);
setShowProjectSelect(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to clear project');
}
};
if (isLoading) {
return (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 p-4 shadow-lg">
<div className="max-w-7xl mx-auto flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
</div>
);
}
return (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 p-4 shadow-lg z-50">
<div className="max-w-7xl mx-auto flex items-center justify-between">
{ongoingTimer ? (
<>
{/* Running Timer Display */}
<div className="flex items-center space-x-4 flex-1">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
<span className="text-2xl font-mono font-bold text-gray-900">
{formatDuration(elapsedSeconds)}
</span>
</div>
{/* Project Selector */}
<div className="relative">
<button
onClick={() => setShowProjectSelect(!showProjectSelect)}
className="flex items-center space-x-2 px-3 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
{ongoingTimer.project ? (
<>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: ongoingTimer.project.color || '#6b7280' }}
/>
<span className="text-sm font-medium text-gray-700">
{ongoingTimer.project.name}
</span>
</>
) : (
<span className="text-sm font-medium text-gray-500">
Select project...
</span>
)}
<ChevronDown className="h-4 w-4 text-gray-500" />
</button>
{showProjectSelect && (
<div className="absolute bottom-full left-0 mb-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 max-h-64 overflow-y-auto">
<button
onClick={handleClearProject}
className="w-full px-4 py-2 text-left text-sm text-gray-500 hover:bg-gray-50 border-b border-gray-100"
>
No project
</button>
{projects?.map((project) => (
<button
key={project.id}
onClick={() => handleProjectChange(project.id)}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center space-x-2"
>
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: project.color || '#6b7280' }}
/>
<div className="min-w-0">
<div className="font-medium text-gray-900 truncate">{project.name}</div>
<div className="text-xs text-gray-500 truncate">{project.client.name}</div>
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Stop Button */}
<button
onClick={handleStop}
className="flex items-center space-x-2 px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
>
<Square className="h-5 w-5 fill-current" />
<span>Stop</span>
</button>
</>
) : (
<>
{/* Stopped Timer Display */}
<div className="flex items-center space-x-2">
<span className="text-gray-500">Ready to track time</span>
</div>
{/* Start Button */}
<button
onClick={handleStart}
className="flex items-center space-x-2 px-6 py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 transition-colors"
>
<Play className="h-5 w-5 fill-current" />
<span>Start</span>
</button>
</>
)}
</div>
{error && (
<div className="max-w-7xl mx-auto mt-2">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
</div>
);
}