refactoring

This commit is contained in:
simon.franken
2026-02-18 10:26:15 +01:00
parent 27ec450d3b
commit 6a6a3ba00b
18 changed files with 386 additions and 371 deletions

View File

@@ -0,0 +1,4 @@
-- Remove the erroneous UNIQUE constraint on ongoing_timers.project_id.
-- Multiple users must be able to have concurrent timers on the same project.
-- The one-timer-per-user constraint is correctly enforced by the UNIQUE on user_id.
DROP INDEX IF EXISTS "ongoing_timers_project_id_key";

View File

@@ -51,7 +51,7 @@ model Project {
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade) client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
timeEntries TimeEntry[] timeEntries TimeEntry[]
ongoingTimer OngoingTimer? ongoingTimers OngoingTimer[]
@@index([userId]) @@index([userId])
@@index([clientId]) @@index([clientId])
@@ -85,7 +85,7 @@ model OngoingTimer {
userId String @map("user_id") @db.VarChar(255) @unique userId String @map("user_id") @db.VarChar(255) @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
projectId String? @unique @map("project_id") projectId String? @map("project_id")
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
@@index([userId]) @@index([userId])

View File

@@ -40,8 +40,8 @@ export function validateConfig(): void {
} }
if (config.session.secret.length < 32) { if (config.session.secret.length < 32) {
console.warn( throw new Error(
"Warning: SESSION_SECRET should be at least 32 characters for security", "SESSION_SECRET must be at least 32 characters. Set a strong secret in your environment.",
); );
} }
} }

View File

@@ -6,7 +6,7 @@ import {
handleCallback, handleCallback,
getUserInfo, getUserInfo,
} from "../auth/oidc"; } from "../auth/oidc";
import { syncUser } from "../middleware/auth"; import { requireAuth, syncUser } from "../middleware/auth";
import type { AuthenticatedRequest } from "../types"; import type { AuthenticatedRequest } from "../types";
const router = Router(); const router = Router();
@@ -84,12 +84,8 @@ router.post("/logout", (req: AuthenticatedRequest, res) => {
}); });
// GET /auth/me - Get current user // GET /auth/me - Get current user
router.get("/me", (req: AuthenticatedRequest, res) => { router.get("/me", requireAuth, (req: AuthenticatedRequest, res) => {
if (!req.session?.user) { res.json(req.user);
res.status(401).json({ error: "Not authenticated" });
return;
}
res.json(req.session.user);
}); });
export default router; export default router;

View File

@@ -1,9 +1,11 @@
import { prisma } from "../prisma/client"; import { prisma } from "../prisma/client";
import { Prisma } from "@prisma/client";
import { import {
NotFoundError, NotFoundError,
BadRequestError, BadRequestError,
ConflictError, ConflictError,
} from "../errors/AppError"; } from "../errors/AppError";
import { hasOverlappingEntries } from "../utils/timeUtils";
import type { import type {
CreateTimeEntryInput, CreateTimeEntryInput,
UpdateTimeEntryInput, UpdateTimeEntryInput,
@@ -14,131 +16,93 @@ import type {
export class TimeEntryService { export class TimeEntryService {
async getStatistics(userId: string, filters: StatisticsFilters = {}) { async getStatistics(userId: string, filters: StatisticsFilters = {}) {
const { startDate, endDate, projectId, clientId } = filters; const { startDate, endDate, projectId, clientId } = filters;
const where: {
userId: string;
startTime?: { gte?: Date; lte?: Date };
projectId?: string;
project?: { clientId?: string };
} = { userId };
if (startDate || endDate) { // Build an array of safe Prisma SQL filter fragments to append as AND clauses.
where.startTime = {}; const extraFilters: Prisma.Sql[] = [];
if (startDate) where.startTime.gte = new Date(startDate); if (startDate) extraFilters.push(Prisma.sql`AND te.start_time >= ${new Date(startDate)}`);
if (endDate) where.startTime.lte = new Date(endDate); if (endDate) extraFilters.push(Prisma.sql`AND te.start_time <= ${new Date(endDate)}`);
} if (projectId) extraFilters.push(Prisma.sql`AND te.project_id = ${projectId}`);
if (clientId) extraFilters.push(Prisma.sql`AND p.client_id = ${clientId}`);
if (projectId) { const filterClause = extraFilters.length
where.projectId = projectId; ? Prisma.join(extraFilters, " ")
} : Prisma.empty;
if (clientId) { const [projectGroups, clientGroups, totalAgg] = await Promise.all([
where.project = { clientId }; prisma.$queryRaw<
}
const entries = await prisma.timeEntry.findMany({
where,
include: {
project: {
select: {
id: true,
name: true,
color: true,
client: {
select: {
id: true,
name: true,
},
},
},
},
},
});
// Calculate total duration in seconds
const totalSeconds = entries.reduce((total, entry) => {
const startTime = new Date(entry.startTime);
const endTime = new Date(entry.endTime);
return (
total + Math.floor((endTime.getTime() - startTime.getTime()) / 1000)
);
}, 0);
// Calculate by project
const byProject = entries.reduce(
(acc, entry) => {
const projectId = entry.project.id;
const projectName = entry.project.name;
const projectColor = entry.project.color;
const startTime = new Date(entry.startTime);
const endTime = new Date(entry.endTime);
const duration = Math.floor(
(endTime.getTime() - startTime.getTime()) / 1000,
);
if (!acc[projectId]) {
acc[projectId] = {
projectId,
projectName,
projectColor,
totalSeconds: 0,
entryCount: 0,
};
}
acc[projectId].totalSeconds += duration;
acc[projectId].entryCount += 1;
return acc;
},
{} as Record<
string,
{ {
projectId: string; project_id: string;
projectName: string; project_name: string;
projectColor: string | null; project_color: string | null;
totalSeconds: number; total_seconds: bigint;
entryCount: number; entry_count: bigint;
} }[]
>, >(Prisma.sql`
); SELECT
p.id AS project_id,
p.name AS project_name,
p.color AS project_color,
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds,
COUNT(te.id)::bigint AS entry_count
FROM time_entries te
JOIN projects p ON p.id = te.project_id
WHERE te.user_id = ${userId}
${filterClause}
GROUP BY p.id, p.name, p.color
ORDER BY total_seconds DESC
`),
// Calculate by client prisma.$queryRaw<
const byClient = entries.reduce(
(acc, entry) => {
const clientId = entry.project.client.id;
const clientName = entry.project.client.name;
const startTime = new Date(entry.startTime);
const endTime = new Date(entry.endTime);
const duration = Math.floor(
(endTime.getTime() - startTime.getTime()) / 1000,
);
if (!acc[clientId]) {
acc[clientId] = {
clientId,
clientName,
totalSeconds: 0,
entryCount: 0,
};
}
acc[clientId].totalSeconds += duration;
acc[clientId].entryCount += 1;
return acc;
},
{} as Record<
string,
{ {
clientId: string; client_id: string;
clientName: string; client_name: string;
totalSeconds: number; total_seconds: bigint;
entryCount: number; entry_count: bigint;
} }[]
>, >(Prisma.sql`
); SELECT
c.id AS client_id,
c.name AS client_name,
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds,
COUNT(te.id)::bigint AS entry_count
FROM time_entries te
JOIN projects p ON p.id = te.project_id
JOIN clients c ON c.id = p.client_id
WHERE te.user_id = ${userId}
${filterClause}
GROUP BY c.id, c.name
ORDER BY total_seconds DESC
`),
prisma.$queryRaw<{ total_seconds: bigint; entry_count: bigint }[]>(
Prisma.sql`
SELECT
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds,
COUNT(te.id)::bigint AS entry_count
FROM time_entries te
JOIN projects p ON p.id = te.project_id
WHERE te.user_id = ${userId}
${filterClause}
`,
),
]);
return { return {
totalSeconds, totalSeconds: Number(totalAgg[0]?.total_seconds ?? 0),
entryCount: entries.length, entryCount: Number(totalAgg[0]?.entry_count ?? 0),
byProject: Object.values(byProject), byProject: projectGroups.map((r) => ({
byClient: Object.values(byClient), projectId: r.project_id,
projectName: r.project_name,
projectColor: r.project_color,
totalSeconds: Number(r.total_seconds),
entryCount: Number(r.entry_count),
})),
byClient: clientGroups.map((r) => ({
clientId: r.client_id,
clientName: r.client_name,
totalSeconds: Number(r.total_seconds),
entryCount: Number(r.entry_count),
})),
filters: { filters: {
startDate: startDate || null, startDate: startDate || null,
endDate: endDate || null, endDate: endDate || null,
@@ -256,7 +220,7 @@ export class TimeEntryService {
} }
// Check for overlapping entries // Check for overlapping entries
const hasOverlap = await this.hasOverlappingEntries( const hasOverlap = await hasOverlappingEntries(
userId, userId,
startTime, startTime,
endTime, endTime,
@@ -321,7 +285,7 @@ export class TimeEntryService {
} }
// Check for overlapping entries (excluding this entry) // Check for overlapping entries (excluding this entry)
const hasOverlap = await this.hasOverlappingEntries( const hasOverlap = await hasOverlappingEntries(
userId, userId,
startTime, startTime,
endTime, endTime,
@@ -369,33 +333,4 @@ export class TimeEntryService {
where: { id }, where: { id },
}); });
} }
private async hasOverlappingEntries(
userId: string,
startTime: Date,
endTime: Date,
excludeId?: string,
): Promise<boolean> {
const where: {
userId: string;
id?: { not: string };
OR: Array<{
startTime?: { lt: Date };
endTime?: { gt: Date };
}>;
} = {
userId,
OR: [
// Entry starts during the new entry
{ startTime: { lt: endTime }, endTime: { gt: startTime } },
],
};
if (excludeId) {
where.id = { not: excludeId };
}
const count = await prisma.timeEntry.count({ where });
return count > 0;
}
} }

View File

@@ -4,6 +4,7 @@ import {
BadRequestError, BadRequestError,
ConflictError, ConflictError,
} from "../errors/AppError"; } from "../errors/AppError";
import { hasOverlappingEntries } from "../utils/timeUtils";
import type { import type {
StartTimerInput, StartTimerInput,
UpdateTimerInput, UpdateTimerInput,
@@ -155,7 +156,7 @@ export class TimerService {
const startTime = timer.startTime; const startTime = timer.startTime;
// Check for overlapping entries // Check for overlapping entries
const hasOverlap = await this.hasOverlappingEntries( const hasOverlap = await hasOverlappingEntries(
userId, userId,
startTime, startTime,
endTime, endTime,
@@ -198,18 +199,4 @@ export class TimerService {
return result[1]; // Return the created time entry return result[1]; // Return the created time entry
} }
private async hasOverlappingEntries(
userId: string,
startTime: Date,
endTime: Date,
): Promise<boolean> {
const count = await prisma.timeEntry.count({
where: {
userId,
OR: [{ startTime: { lt: endTime }, endTime: { gt: startTime } }],
},
});
return count > 0;
}
} }

View File

@@ -0,0 +1,29 @@
import { prisma } from "../prisma/client";
/**
* Checks whether a user already has a time entry that overlaps with the given
* [startTime, endTime] interval.
*
* @param userId - The user whose entries are checked.
* @param startTime - Start of the interval to check.
* @param endTime - End of the interval to check.
* @param excludeId - Optional time-entry id to exclude (used when updating an
* existing entry so it does not collide with itself).
*/
export async function hasOverlappingEntries(
userId: string,
startTime: Date,
endTime: Date,
excludeId?: string,
): Promise<boolean> {
const count = await prisma.timeEntry.count({
where: {
userId,
...(excludeId ? { id: { not: excludeId } } : {}),
// An entry overlaps when it starts before our end AND ends after our start.
startTime: { lt: endTime },
endTime: { gt: startTime },
},
});
return count > 0;
}

View File

@@ -0,0 +1,39 @@
import { X } from 'lucide-react';
interface ModalProps {
title: string;
onClose: () => void;
children: React.ReactNode;
/** Optional override for the max-width of the modal panel (default: max-w-md) */
maxWidth?: string;
}
/**
* Generic modal overlay with a header and close button.
* Render form content (or any JSX) as children.
*
* @example
* <Modal title="Add Client" onClose={handleClose}>
* <form onSubmit={handleSubmit}>...</form>
* </Modal>
*/
export function Modal({ title, onClose, children, maxWidth = 'max-w-md' }: ModalProps) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className={`bg-white rounded-lg shadow-xl ${maxWidth} w-full`}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button
type="button"
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
interface ProjectColorDotProps {
color: string | null | undefined;
/** Tailwind size classes (default: w-3 h-3) */
size?: string;
}
/** A small filled circle used to represent a project's colour. */
export function ProjectColorDot({ color, size = 'w-3 h-3' }: ProjectColorDotProps) {
return (
<div
className={`${size} rounded-full flex-shrink-0`}
style={{ backgroundColor: color || '#6b7280' }}
/>
);
}

View File

@@ -1,5 +1,6 @@
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Spinner } from '@/components/Spinner';
interface ProtectedRouteProps { interface ProtectedRouteProps {
children: React.ReactNode; children: React.ReactNode;
@@ -11,7 +12,7 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <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> <Spinner />
</div> </div>
); );
} }

View File

@@ -0,0 +1,12 @@
interface SpinnerProps {
/** Height/width class (default: h-12 w-12) */
size?: string;
}
export function Spinner({ size = 'h-12 w-12' }: SpinnerProps) {
return (
<div className="flex items-center justify-center h-64">
<div className={`animate-spin rounded-full ${size} border-b-2 border-primary-600`} />
</div>
);
}

View File

@@ -0,0 +1,29 @@
interface StatCardProps {
icon: React.ElementType;
label: string;
value: string;
color: 'blue' | 'green' | 'purple' | 'orange';
}
const colorClasses: Record<StatCardProps['color'], string> = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
purple: 'bg-purple-50 text-purple-600',
orange: 'bg-orange-50 text-orange-600',
};
export function StatCard({ icon: Icon, label, value, color }: StatCardProps) {
return (
<div className="card p-4">
<div className="flex items-center">
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
<Icon className="h-6 w-6" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">{label}</p>
<p className="text-2xl font-bold text-gray-900">{value}</p>
</div>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { Play, Square, ChevronDown } from 'lucide-react';
import { useTimer } from '@/contexts/TimerContext'; import { useTimer } from '@/contexts/TimerContext';
import { useProjects } from '@/hooks/useProjects'; import { useProjects } from '@/hooks/useProjects';
import { formatDuration } from '@/utils/dateUtils'; import { formatDuration } from '@/utils/dateUtils';
import { ProjectColorDot } from '@/components/ProjectColorDot';
export function TimerWidget() { export function TimerWidget() {
const { ongoingTimer, isLoading, elapsedSeconds, startTimer, stopTimer, updateTimerProject } = useTimer(); const { ongoingTimer, isLoading, elapsedSeconds, startTimer, stopTimer, updateTimerProject } = useTimer();
@@ -80,10 +81,7 @@ export function TimerWidget() {
> >
{ongoingTimer.project ? ( {ongoingTimer.project ? (
<> <>
<div <ProjectColorDot color={ongoingTimer.project.color} />
className="w-3 h-3 rounded-full"
style={{ backgroundColor: ongoingTimer.project.color || '#6b7280' }}
/>
<span className="text-sm font-medium text-gray-700"> <span className="text-sm font-medium text-gray-700">
{ongoingTimer.project.name} {ongoingTimer.project.name}
</span> </span>
@@ -110,10 +108,7 @@ export function TimerWidget() {
onClick={() => handleProjectChange(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" className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center space-x-2"
> >
<div <ProjectColorDot color={project.color} />
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: project.color || '#6b7280' }}
/>
<div className="min-w-0"> <div className="min-w-0">
<div className="font-medium text-gray-900 truncate">{project.name}</div> <div className="font-medium text-gray-900 truncate">{project.name}</div>
<div className="text-xs text-gray-500 truncate">{project.client.name}</div> <div className="text-xs text-gray-500 truncate">{project.client.name}</div>

View File

@@ -1,6 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { Plus, Edit2, Trash2, Building2 } from 'lucide-react'; import { Plus, Edit2, Trash2, Building2 } from 'lucide-react';
import { useClients } from '@/hooks/useClients'; import { useClients } from '@/hooks/useClients';
import { Modal } from '@/components/Modal';
import { Spinner } from '@/components/Spinner';
import type { Client, CreateClientInput, UpdateClientInput } from '@/types'; import type { Client, CreateClientInput, UpdateClientInput } from '@/types';
export function ClientsPage() { export function ClientsPage() {
@@ -66,11 +68,7 @@ export function ClientsPage() {
}; };
if (isLoading) { if (isLoading) {
return ( return <Spinner />;
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
);
} }
return ( return (
@@ -145,15 +143,11 @@ export function ClientsPage() {
{/* Modal */} {/* Modal */}
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <Modal
<div className="bg-white rounded-lg shadow-xl max-w-md w-full"> title={editingClient ? 'Edit Client' : 'Add Client'}
<div className="px-6 py-4 border-b border-gray-200"> onClose={handleCloseModal}
<h2 className="text-lg font-semibold text-gray-900"> >
{editingClient ? 'Edit Client' : 'Add Client'} <form onSubmit={handleSubmit} className="space-y-4">
</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && ( {error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm"> <div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">
{error} {error}
@@ -204,8 +198,7 @@ export function ClientsPage() {
</button> </button>
</div> </div>
</form> </form>
</div> </Modal>
</div>
)} )}
</div> </div>
); );

View File

@@ -1,10 +1,13 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Clock, Calendar, Briefcase, TrendingUp } from "lucide-react"; import { Clock, Calendar, Briefcase, TrendingUp } from "lucide-react";
import { useTimeEntries } from "@/hooks/useTimeEntries"; import { useTimeEntries } from "@/hooks/useTimeEntries";
import { ProjectColorDot } from "@/components/ProjectColorDot";
import { StatCard } from "@/components/StatCard";
import { import {
formatDate, formatDate,
formatDurationFromDates, formatDurationFromDates,
formatDurationHoursMinutes, formatDurationHoursMinutes,
calculateDuration,
} from "@/utils/dateUtils"; } from "@/utils/dateUtils";
import { startOfDay, endOfDay } from "date-fns"; import { startOfDay, endOfDay } from "date-fns";
@@ -106,13 +109,8 @@ export function DashboardPage() {
<tr key={entry.id}> <tr key={entry.id}>
<td className="px-4 py-3 whitespace-nowrap"> <td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center"> <div className="flex items-center">
<div <ProjectColorDot color={entry.project.color} />
className="w-3 h-3 rounded-full mr-2" <div className="ml-2">
style={{
backgroundColor: entry.project.color || "#6b7280",
}}
/>
<div>
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
{entry.project.name} {entry.project.name}
</div> </div>
@@ -138,39 +136,3 @@ export function DashboardPage() {
</div> </div>
); );
} }
interface StatCardProps {
icon: React.ElementType;
label: string;
value: string;
color: "blue" | "green" | "purple" | "orange";
}
function StatCard({ icon: Icon, label, value, color }: StatCardProps) {
const colors = {
blue: "bg-blue-50 text-blue-600",
green: "bg-green-50 text-green-600",
purple: "bg-purple-50 text-purple-600",
orange: "bg-orange-50 text-orange-600",
};
return (
<div className="card p-4">
<div className="flex items-center">
<div className={`p-3 rounded-lg ${colors[color]}`}>
<Icon className="h-6 w-6" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">{label}</p>
<p className="text-2xl font-bold text-gray-900">{value}</p>
</div>
</div>
</div>
);
}
function calculateDuration(startTime: string, endTime: string): number {
return Math.floor(
(new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000,
);
}

View File

@@ -2,6 +2,9 @@ import { useState } from 'react';
import { Plus, Edit2, Trash2, FolderOpen } from 'lucide-react'; import { Plus, Edit2, Trash2, FolderOpen } from 'lucide-react';
import { useProjects } from '@/hooks/useProjects'; import { useProjects } from '@/hooks/useProjects';
import { useClients } from '@/hooks/useClients'; import { useClients } from '@/hooks/useClients';
import { Modal } from '@/components/Modal';
import { Spinner } from '@/components/Spinner';
import { ProjectColorDot } from '@/components/ProjectColorDot';
import type { Project, CreateProjectInput, UpdateProjectInput } from '@/types'; import type { Project, CreateProjectInput, UpdateProjectInput } from '@/types';
const PRESET_COLORS = [ const PRESET_COLORS = [
@@ -97,11 +100,7 @@ export function ProjectsPage() {
}; };
if (isLoading) { if (isLoading) {
return ( return <Spinner />;
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
);
} }
if (!clients?.length) { if (!clients?.length) {
@@ -134,7 +133,7 @@ export function ProjectsPage() {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: project.color || '#6b7280' }} /> <ProjectColorDot color={project.color} size="w-4 h-4" />
<h3 className="font-medium text-gray-900 truncate">{project.name}</h3> <h3 className="font-medium text-gray-900 truncate">{project.name}</h3>
</div> </div>
<p className="mt-1 text-sm text-gray-500">{project.client.name}</p> <p className="mt-1 text-sm text-gray-500">{project.client.name}</p>
@@ -153,9 +152,10 @@ export function ProjectsPage() {
</div> </div>
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <Modal
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6"> title={editingProject ? 'Edit Project' : 'Add Project'}
<h2 className="text-lg font-semibold mb-4">{editingProject ? 'Edit Project' : 'Add Project'}</h2> onClose={handleCloseModal}
>
{error && <div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>} {error && <div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
@@ -184,11 +184,20 @@ export function ProjectsPage() {
</div> </div>
<div className="flex justify-end space-x-3 pt-2"> <div className="flex justify-end space-x-3 pt-2">
<button type="button" onClick={handleCloseModal} className="btn-secondary">Cancel</button> <button type="button" onClick={handleCloseModal} className="btn-secondary">Cancel</button>
<button type="submit" className="btn-primary">{editingProject ? 'Save' : 'Create'}</button> <button
type="submit"
className="btn-primary"
disabled={createProject.isPending || updateProject.isPending}
>
{createProject.isPending || updateProject.isPending
? 'Saving...'
: editingProject
? 'Save'
: 'Create'}
</button>
</div> </div>
</form> </form>
</div> </Modal>
</div>
)} )}
</div> </div>
); );

View File

@@ -9,7 +9,8 @@ import {
import { useStatistics } from "@/hooks/useTimeEntries"; import { useStatistics } from "@/hooks/useTimeEntries";
import { useClients } from "@/hooks/useClients"; import { useClients } from "@/hooks/useClients";
import { useProjects } from "@/hooks/useProjects"; import { useProjects } from "@/hooks/useProjects";
import { formatDuration } from "@/utils/dateUtils"; import { ProjectColorDot } from "@/components/ProjectColorDot";
import { formatDuration, toISOTimezone } from "@/utils/dateUtils";
import type { StatisticsFilters } from "@/types"; import type { StatisticsFilters } from "@/types";
export function StatisticsPage() { export function StatisticsPage() {
@@ -64,7 +65,7 @@ export function StatisticsPage() {
onChange={(e) => onChange={(e) =>
handleFilterChange( handleFilterChange(
"startDate", "startDate",
e.target.value ? `${e.target.value}T00:00:00` : undefined, e.target.value ? toISOTimezone(`${e.target.value}T00:00:00`) : undefined,
) )
} }
className="input" className="input"
@@ -84,7 +85,7 @@ export function StatisticsPage() {
onChange={(e) => onChange={(e) =>
handleFilterChange( handleFilterChange(
"endDate", "endDate",
e.target.value ? `${e.target.value}T23:59:59` : undefined, e.target.value ? toISOTimezone(`${e.target.value}T23:59:59`) : undefined,
) )
} }
className="input" className="input"
@@ -192,12 +193,7 @@ export function StatisticsPage() {
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg" className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <ProjectColorDot color={project.projectColor} />
className="w-3 h-3 rounded-full"
style={{
backgroundColor: project.projectColor || "#6b7280",
}}
/>
<span className="font-medium text-gray-900"> <span className="font-medium text-gray-900">
{project.projectName} {project.projectName}
</span> </span>

View File

@@ -2,6 +2,9 @@ import { useState } from 'react';
import { Plus, Edit2, Trash2 } from 'lucide-react'; import { Plus, Edit2, Trash2 } from 'lucide-react';
import { useTimeEntries } from '@/hooks/useTimeEntries'; import { useTimeEntries } from '@/hooks/useTimeEntries';
import { useProjects } from '@/hooks/useProjects'; import { useProjects } from '@/hooks/useProjects';
import { Modal } from '@/components/Modal';
import { Spinner } from '@/components/Spinner';
import { ProjectColorDot } from '@/components/ProjectColorDot';
import { formatDate, formatDurationFromDates, getLocalISOString, toISOTimezone } from '@/utils/dateUtils'; import { formatDate, formatDurationFromDates, getLocalISOString, toISOTimezone } from '@/utils/dateUtils';
import type { TimeEntry, CreateTimeEntryInput, UpdateTimeEntryInput } from '@/types'; import type { TimeEntry, CreateTimeEntryInput, UpdateTimeEntryInput } from '@/types';
@@ -86,7 +89,7 @@ export function TimeEntriesPage() {
}; };
if (isLoading) { if (isLoading) {
return <div className="flex justify-center h-64 items-center"><div className="animate-spin h-12 w-12 border-b-2 border-primary-600 rounded-full"></div></div>; return <Spinner />;
} }
return ( return (
@@ -117,8 +120,8 @@ export function TimeEntriesPage() {
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{formatDate(entry.startTime)}</td> <td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{formatDate(entry.startTime)}</td>
<td className="px-4 py-3 whitespace-nowrap"> <td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center"> <div className="flex items-center">
<div className="w-3 h-3 rounded-full mr-2" style={{ backgroundColor: entry.project.color || '#6b7280' }} /> <ProjectColorDot color={entry.project.color} />
<div> <div className="ml-2">
<div className="text-sm font-medium text-gray-900">{entry.project.name}</div> <div className="text-sm font-medium text-gray-900">{entry.project.name}</div>
<div className="text-xs text-gray-500">{entry.project.client.name}</div> <div className="text-xs text-gray-500">{entry.project.client.name}</div>
</div> </div>
@@ -139,9 +142,10 @@ export function TimeEntriesPage() {
</div> </div>
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <Modal
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6"> title={editingEntry ? 'Edit Entry' : 'Add Entry'}
<h2 className="text-lg font-semibold mb-4">{editingEntry ? 'Edit Entry' : 'Add Entry'}</h2> onClose={handleCloseModal}
>
{error && <div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>} {error && <div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
@@ -166,11 +170,20 @@ export function TimeEntriesPage() {
</div> </div>
<div className="flex justify-end space-x-3 pt-2"> <div className="flex justify-end space-x-3 pt-2">
<button type="button" onClick={handleCloseModal} className="btn-secondary">Cancel</button> <button type="button" onClick={handleCloseModal} className="btn-secondary">Cancel</button>
<button type="submit" className="btn-primary">{editingEntry ? 'Save' : 'Create'}</button> <button
type="submit"
className="btn-primary"
disabled={createTimeEntry.isPending || updateTimeEntry.isPending}
>
{createTimeEntry.isPending || updateTimeEntry.isPending
? 'Saving...'
: editingEntry
? 'Save'
: 'Create'}
</button>
</div> </div>
</form> </form>
</div> </Modal>
</div>
)} )}
</div> </div>
); );