refactoring
This commit is contained in:
@@ -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";
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -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.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
29
backend/src/utils/timeUtils.ts
Normal file
29
backend/src/utils/timeUtils.ts
Normal 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;
|
||||||
|
}
|
||||||
39
frontend/src/components/Modal.tsx
Normal file
39
frontend/src/components/Modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/components/ProjectColorDot.tsx
Normal file
15
frontend/src/components/ProjectColorDot.tsx
Normal 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' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
12
frontend/src/components/Spinner.tsx
Normal file
12
frontend/src/components/Spinner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
frontend/src/components/StatCard.tsx
Normal file
29
frontend/src/components/StatCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,67 +143,62 @@ 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>
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Client Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="input"
|
||||||
|
placeholder="Enter client name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<div>
|
||||||
{error && (
|
<label className="label">Description (Optional)</label>
|
||||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">
|
<textarea
|
||||||
{error}
|
value={formData.description}
|
||||||
</div>
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
)}
|
className="input"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Enter description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
<label className="label">Client Name</label>
|
<button
|
||||||
<input
|
type="button"
|
||||||
type="text"
|
onClick={handleCloseModal}
|
||||||
value={formData.name}
|
className="btn-secondary"
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
>
|
||||||
className="input"
|
Cancel
|
||||||
placeholder="Enter client name"
|
</button>
|
||||||
autoFocus
|
<button
|
||||||
/>
|
type="submit"
|
||||||
</div>
|
className="btn-primary"
|
||||||
|
disabled={createClient.isPending || updateClient.isPending}
|
||||||
<div>
|
>
|
||||||
<label className="label">Description (Optional)</label>
|
{createClient.isPending || updateClient.isPending
|
||||||
<textarea
|
? 'Saving...'
|
||||||
value={formData.description}
|
: editingClient
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
? 'Save Changes'
|
||||||
className="input"
|
: 'Add Client'}
|
||||||
rows={3}
|
</button>
|
||||||
placeholder="Enter description"
|
</div>
|
||||||
/>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
className="btn-secondary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-primary"
|
|
||||||
disabled={createClient.isPending || updateClient.isPending}
|
|
||||||
>
|
|
||||||
{createClient.isPending || updateClient.isPending
|
|
||||||
? 'Saving...'
|
|
||||||
: editingClient
|
|
||||||
? 'Save Changes'
|
|
||||||
: 'Add Client'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,42 +152,52 @@ 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>}
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
{error && <div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>}
|
||||||
<div>
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<label className="label">Project Name</label>
|
<div>
|
||||||
<input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} className="input" />
|
<label className="label">Project Name</label>
|
||||||
|
<input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Client</label>
|
||||||
|
<select value={formData.clientId} onChange={(e) => setFormData({ ...formData, clientId: e.target.value })} className="input">
|
||||||
|
{clients?.map((client) => (
|
||||||
|
<option key={client.id} value={client.id}>{client.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRESET_COLORS.map((color) => (
|
||||||
|
<button key={color} type="button" onClick={() => setFormData({ ...formData, color })} className={`w-8 h-8 rounded-full ${formData.color === color ? 'ring-2 ring-offset-2 ring-gray-400' : ''}`} style={{ backgroundColor: color }} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<label className="label">Client</label>
|
<div>
|
||||||
<select value={formData.clientId} onChange={(e) => setFormData({ ...formData, clientId: e.target.value })} className="input">
|
<label className="label">Description</label>
|
||||||
{clients?.map((client) => (
|
<textarea value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} className="input" rows={2} />
|
||||||
<option key={client.id} value={client.id}>{client.name}</option>
|
</div>
|
||||||
))}
|
<div className="flex justify-end space-x-3 pt-2">
|
||||||
</select>
|
<button type="button" onClick={handleCloseModal} className="btn-secondary">Cancel</button>
|
||||||
</div>
|
<button
|
||||||
<div>
|
type="submit"
|
||||||
<label className="label">Color</label>
|
className="btn-primary"
|
||||||
<div className="flex flex-wrap gap-2">
|
disabled={createProject.isPending || updateProject.isPending}
|
||||||
{PRESET_COLORS.map((color) => (
|
>
|
||||||
<button key={color} type="button" onClick={() => setFormData({ ...formData, color })} className={`w-8 h-8 rounded-full ${formData.color === color ? 'ring-2 ring-offset-2 ring-gray-400' : ''}`} style={{ backgroundColor: color }} />
|
{createProject.isPending || updateProject.isPending
|
||||||
))}
|
? 'Saving...'
|
||||||
</div>
|
: editingProject
|
||||||
</div>
|
? 'Save'
|
||||||
<div>
|
: 'Create'}
|
||||||
<label className="label">Description</label>
|
</button>
|
||||||
<textarea value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} className="input" rows={2} />
|
</div>
|
||||||
</div>
|
</form>
|
||||||
<div className="flex justify-end space-x-3 pt-2">
|
</Modal>
|
||||||
<button type="button" onClick={handleCloseModal} className="btn-secondary">Cancel</button>
|
|
||||||
<button type="submit" className="btn-primary">{editingProject ? 'Save' : 'Create'}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,13 +193,8 @@ 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"
|
<span className="font-medium text-gray-900">
|
||||||
style={{
|
|
||||||
backgroundColor: project.projectColor || "#6b7280",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-gray-900">
|
|
||||||
{project.projectName}
|
{project.projectName}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
|
|||||||
@@ -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,38 +142,48 @@ 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>}
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
{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">
|
||||||
|
<div>
|
||||||
|
<label className="label">Project</label>
|
||||||
|
<select value={formData.projectId} onChange={(e) => setFormData({ ...formData, projectId: e.target.value })} className="input">
|
||||||
|
{projects?.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Project</label>
|
<label className="label">Start</label>
|
||||||
<select value={formData.projectId} onChange={(e) => setFormData({ ...formData, projectId: e.target.value })} className="input">
|
<input type="datetime-local" value={formData.startTime} onChange={(e) => setFormData({ ...formData, startTime: e.target.value })} className="input" />
|
||||||
{projects?.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="label">Start</label>
|
|
||||||
<input type="datetime-local" value={formData.startTime} onChange={(e) => setFormData({ ...formData, startTime: e.target.value })} className="input" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">End</label>
|
|
||||||
<input type="datetime-local" value={formData.endTime} onChange={(e) => setFormData({ ...formData, endTime: e.target.value })} className="input" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Description</label>
|
<label className="label">End</label>
|
||||||
<textarea value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} className="input" rows={2} />
|
<input type="datetime-local" value={formData.endTime} onChange={(e) => setFormData({ ...formData, endTime: e.target.value })} className="input" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end space-x-3 pt-2">
|
</div>
|
||||||
<button type="button" onClick={handleCloseModal} className="btn-secondary">Cancel</button>
|
<div>
|
||||||
<button type="submit" className="btn-primary">{editingEntry ? 'Save' : 'Create'}</button>
|
<label className="label">Description</label>
|
||||||
</div>
|
<textarea value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} className="input" rows={2} />
|
||||||
</form>
|
</div>
|
||||||
</div>
|
<div className="flex justify-end space-x-3 pt-2">
|
||||||
</div>
|
<button type="button" onClick={handleCloseModal} className="btn-secondary">Cancel</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={createTimeEntry.isPending || updateTimeEntry.isPending}
|
||||||
|
>
|
||||||
|
{createTimeEntry.isPending || updateTimeEntry.isPending
|
||||||
|
? 'Saving...'
|
||||||
|
: editingEntry
|
||||||
|
? 'Save'
|
||||||
|
: 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user