improvements

This commit is contained in:
simon.franken
2026-02-18 15:26:36 +01:00
parent 4cce62934e
commit f5c0a0b2f7
8 changed files with 354 additions and 179 deletions

View File

@@ -0,0 +1,35 @@
import { Modal } from '@/components/Modal';
interface ConfirmModalProps {
title: string;
message: string;
confirmLabel?: string;
onConfirm: () => void;
onClose: () => void;
}
export function ConfirmModal({
title,
message,
confirmLabel = 'Delete',
onConfirm,
onClose,
}: ConfirmModalProps) {
return (
<Modal title={title} onClose={onClose} maxWidth="max-w-sm">
<p className="text-sm text-gray-600">{message}</p>
<div className="flex justify-end space-x-3 mt-6">
<button type="button" onClick={onClose} className="btn-secondary">
Cancel
</button>
<button
type="button"
onClick={() => { onConfirm(); onClose(); }}
className="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 transition-colors"
>
{confirmLabel}
</button>
</div>
</Modal>
);
}

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
interface ModalProps { interface ModalProps {
@@ -10,6 +11,7 @@ interface ModalProps {
/** /**
* Generic modal overlay with a header and close button. * Generic modal overlay with a header and close button.
* Closes on Escape key or backdrop click.
* Render form content (or any JSX) as children. * Render form content (or any JSX) as children.
* *
* @example * @example
@@ -18,8 +20,19 @@ interface ModalProps {
* </Modal> * </Modal>
*/ */
export function Modal({ title, onClose, children, maxWidth = 'max-w-md' }: ModalProps) { export function Modal({ title, onClose, children, maxWidth = 'max-w-md' }: ModalProps) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50 !m-0"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className={`bg-white rounded-lg shadow-xl ${maxWidth} w-full`}> <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"> <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> <h2 className="text-lg font-semibold text-gray-900">{title}</h2>

View File

@@ -0,0 +1,122 @@
import { useState } from 'react';
import { Modal } from '@/components/Modal';
import { useProjects } from '@/hooks/useProjects';
import { getLocalISOString, toISOTimezone } from '@/utils/dateUtils';
import type { TimeEntry, CreateTimeEntryInput, UpdateTimeEntryInput } from '@/types';
import type { UseMutationResult } from '@tanstack/react-query';
interface TimeEntryFormModalProps {
entry: TimeEntry | null;
onClose: () => void;
createTimeEntry: UseMutationResult<TimeEntry, Error, CreateTimeEntryInput>;
updateTimeEntry: UseMutationResult<TimeEntry, Error, { id: string; input: UpdateTimeEntryInput }>;
}
export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTimeEntry }: TimeEntryFormModalProps) {
const { projects } = useProjects();
const [formData, setFormData] = useState<CreateTimeEntryInput>(() => {
if (entry) {
return {
startTime: getLocalISOString(new Date(entry.startTime)),
endTime: getLocalISOString(new Date(entry.endTime)),
description: entry.description || '',
projectId: entry.projectId,
};
}
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
return {
startTime: getLocalISOString(oneHourAgo),
endTime: getLocalISOString(now),
description: '',
projectId: projects?.[0]?.id || '',
};
});
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!formData.projectId) {
setError('Please select a project');
return;
}
const input = {
...formData,
startTime: toISOTimezone(formData.startTime),
endTime: toISOTimezone(formData.endTime),
};
try {
if (entry) {
await updateTimeEntry.mutateAsync({ id: entry.id, input: input as UpdateTimeEntryInput });
} else {
await createTimeEntry.mutateAsync(input);
}
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
}
};
return (
<Modal title={entry ? 'Edit Entry' : 'Add Entry'} onClose={onClose}>
{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>
<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>
<label className="label">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input"
rows={2}
/>
</div>
<div className="flex justify-end space-x-3 pt-2">
<button type="button" onClick={onClose} className="btn-secondary">Cancel</button>
<button
type="submit"
className="btn-primary"
disabled={createTimeEntry.isPending || updateTimeEntry.isPending}
>
{createTimeEntry.isPending || updateTimeEntry.isPending ? 'Saving...' : entry ? 'Save' : 'Create'}
</button>
</div>
</form>
</Modal>
);
}

View File

@@ -90,26 +90,38 @@ export function TimerWidget() {
return ( 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="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"> <div className="max-w-7xl mx-auto flex flex-wrap sm:flex-nowrap items-center gap-2 sm:justify-between">
{ongoingTimer ? ( {ongoingTimer ? (
<> <>
{/* Running Timer Display */} {/* Row 1 (mobile): timer + stop side by side. On sm+ dissolves into the parent flex row via contents. */}
<div className="flex items-center space-x-4 flex-1"> <div className="flex items-center justify-between w-full sm:contents">
<div className="flex items-center space-x-2"> {/* Timer Display */}
<div className="flex items-center space-x-2 shrink-0">
<div className="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div> <div className="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
<TimerDisplay totalSeconds={elapsedSeconds} /> <TimerDisplay totalSeconds={elapsedSeconds} />
</div> </div>
{/* Project Selector */} {/* Stop Button */}
<div className="relative"> <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 shrink-0 sm:order-last"
>
<Square className="h-5 w-5 fill-current" />
<span>Stop</span>
</button>
</div>
{/* Project Selector — full width on mobile, auto on desktop */}
<div className="relative w-full sm:w-auto sm:flex-1 sm:mx-4">
<button <button
onClick={() => setShowProjectSelect(!showProjectSelect)} 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" className="flex items-center space-x-2 px-3 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors w-full sm:w-auto"
> >
<span className="flex items-center space-x-2 min-w-0 flex-1">
{ongoingTimer.project ? ( {ongoingTimer.project ? (
<> <>
<ProjectColorDot color={ongoingTimer.project.color} /> <ProjectColorDot color={ongoingTimer.project.color} />
<span className="text-sm font-medium text-gray-700"> <span className="text-sm font-medium text-gray-700 truncate">
{ongoingTimer.project.name} {ongoingTimer.project.name}
</span> </span>
</> </>
@@ -118,11 +130,12 @@ export function TimerWidget() {
Select project... Select project...
</span> </span>
)} )}
<ChevronDown className="h-4 w-4 text-gray-500" /> </span>
<ChevronDown className="h-4 w-4 text-gray-500 shrink-0" />
</button> </button>
{showProjectSelect && ( {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"> <div className="absolute bottom-full left-0 mb-2 w-64 max-w-[calc(100vw-2rem)] bg-white rounded-lg shadow-lg border border-gray-200 max-h-64 overflow-y-auto">
<button <button
onClick={handleClearProject} 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" className="w-full px-4 py-2 text-left text-sm text-gray-500 hover:bg-gray-50 border-b border-gray-100"
@@ -149,19 +162,9 @@ export function TimerWidget() {
</div> </div>
)} )}
</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>
</> </>
) : ( ) : (
<> <div className="flex items-center justify-between w-full">
{/* Stopped Timer Display */} {/* Stopped Timer Display */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="text-gray-500">Ready to track time</span> <span className="text-gray-500">Ready to track time</span>
@@ -175,7 +178,7 @@ export function TimerWidget() {
<Play className="h-5 w-5 fill-current" /> <Play className="h-5 w-5 fill-current" />
<span>Start</span> <span>Start</span>
</button> </button>
</> </div>
)} )}
</div> </div>

View File

@@ -3,6 +3,7 @@ import { Plus, Edit2, Trash2, Building2, Target, ChevronDown, ChevronUp, X } fro
import { useClients } from '@/hooks/useClients'; import { useClients } from '@/hooks/useClients';
import { useClientTargets } from '@/hooks/useClientTargets'; import { useClientTargets } from '@/hooks/useClientTargets';
import { Modal } from '@/components/Modal'; import { Modal } from '@/components/Modal';
import { ConfirmModal } from '@/components/ConfirmModal';
import { Spinner } from '@/components/Spinner'; import { Spinner } from '@/components/Spinner';
import { formatDurationHoursMinutes } from '@/utils/dateUtils'; import { formatDurationHoursMinutes } from '@/utils/dateUtils';
import type { import type {
@@ -129,8 +130,9 @@ function ClientTargetPanel({
} }
}; };
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleDelete = async () => { const handleDelete = async () => {
if (!confirm('Delete this target? All corrections will also be deleted.')) return;
try { try {
await onDeleted(); await onDeleted();
} catch (err) { } catch (err) {
@@ -267,7 +269,7 @@ function ClientTargetPanel({
<Edit2 className="h-3.5 w-3.5" /> <Edit2 className="h-3.5 w-3.5" />
</button> </button>
<button <button
onClick={handleDelete} onClick={() => setShowDeleteConfirm(true)}
className="p-1 text-gray-400 hover:text-red-600 rounded" className="p-1 text-gray-400 hover:text-red-600 rounded"
title="Delete target" title="Delete target"
> >
@@ -380,6 +382,15 @@ function ClientTargetPanel({
)} )}
</div> </div>
)} )}
{showDeleteConfirm && (
<ConfirmModal
title="Delete Target"
message="Delete this target? All corrections will also be deleted."
onConfirm={handleDelete}
onClose={() => setShowDeleteConfirm(false)}
/>
)}
</div> </div>
); );
} }
@@ -436,13 +447,12 @@ export function ClientsPage() {
} }
}; };
const handleDelete = async (client: Client) => { const [confirmClient, setConfirmClient] = useState<Client | null>(null);
if (!confirm(`Are you sure you want to delete "${client.name}"? This will also delete all associated projects and time entries.`)) {
return;
}
const handleDeleteConfirmed = async () => {
if (!confirmClient) return;
try { try {
await deleteClient.mutateAsync(client.id); await deleteClient.mutateAsync(confirmClient.id);
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete client'); alert(err instanceof Error ? err.message : 'Failed to delete client');
} }
@@ -512,7 +522,7 @@ export function ClientsPage() {
<Edit2 className="h-4 w-4" /> <Edit2 className="h-4 w-4" />
</button> </button>
<button <button
onClick={() => handleDelete(client)} onClick={() => setConfirmClient(client)}
className="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50" className="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@@ -595,6 +605,15 @@ export function ClientsPage() {
</form> </form>
</Modal> </Modal>
)} )}
{confirmClient && (
<ConfirmModal
title={`Delete "${confirmClient.name}"`}
message="This will also delete all associated projects and time entries. This action cannot be undone."
onConfirm={handleDeleteConfirmed}
onClose={() => setConfirmClient(null)}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,9 +1,12 @@
import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Clock, Calendar, Briefcase, TrendingUp, Target } from "lucide-react"; import { Clock, Calendar, Briefcase, TrendingUp, Target, Edit2, Trash2 } from "lucide-react";
import { useTimeEntries } from "@/hooks/useTimeEntries"; import { useTimeEntries } from "@/hooks/useTimeEntries";
import { useClientTargets } from "@/hooks/useClientTargets"; import { useClientTargets } from "@/hooks/useClientTargets";
import { ProjectColorDot } from "@/components/ProjectColorDot"; import { ProjectColorDot } from "@/components/ProjectColorDot";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { TimeEntryFormModal } from "@/components/TimeEntryFormModal";
import { ConfirmModal } from "@/components/ConfirmModal";
import { import {
formatDate, formatDate,
formatTime, formatTime,
@@ -12,6 +15,7 @@ import {
calculateDuration, calculateDuration,
} from "@/utils/dateUtils"; } from "@/utils/dateUtils";
import { startOfDay, endOfDay } from "date-fns"; import { startOfDay, endOfDay } from "date-fns";
import type { TimeEntry } from "@/types";
export function DashboardPage() { export function DashboardPage() {
const today = new Date(); const today = new Date();
@@ -21,12 +25,35 @@ export function DashboardPage() {
limit: 5, limit: 5,
}); });
const { data: recentEntries } = useTimeEntries({ const { data: recentEntries, createTimeEntry, updateTimeEntry, deleteTimeEntry } = useTimeEntries({
limit: 10, limit: 10,
}); });
const { targets } = useClientTargets(); const { targets } = useClientTargets();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
const [confirmEntry, setConfirmEntry] = useState<TimeEntry | null>(null);
const handleOpenModal = (entry: TimeEntry) => {
setEditingEntry(entry);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingEntry(null);
};
const handleDeleteConfirmed = async () => {
if (!confirmEntry) return;
try {
await deleteTimeEntry.mutateAsync(confirmEntry.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete');
}
};
const totalTodaySeconds = const totalTodaySeconds =
todayEntries?.entries.reduce((total, entry) => { todayEntries?.entries.reduce((total, entry) => {
return total + calculateDuration(entry.startTime, entry.endTime); return total + calculateDuration(entry.startTime, entry.endTime);
@@ -163,11 +190,14 @@ export function DashboardPage() {
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration Duration
</th> </th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{recentEntries?.entries.slice(0, 5).map((entry) => ( {recentEntries?.entries.slice(0, 5).map((entry) => (
<tr key={entry.id}> <tr key={entry.id} className="hover:bg-gray-50">
<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">
<ProjectColorDot color={entry.project.color} /> <ProjectColorDot color={entry.project.color} />
@@ -188,6 +218,10 @@ export function DashboardPage() {
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono"> <td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
{formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime)} {formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime)}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-right">
<button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button>
<button onClick={() => setConfirmEntry(entry)} className="p-1.5 text-gray-400 hover:text-red-600"><Trash2 className="h-4 w-4" /></button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -195,6 +229,24 @@ export function DashboardPage() {
</div> </div>
)} )}
</div> </div>
{isModalOpen && (
<TimeEntryFormModal
entry={editingEntry}
onClose={handleCloseModal}
createTimeEntry={createTimeEntry}
updateTimeEntry={updateTimeEntry}
/>
)}
{confirmEntry && (
<ConfirmModal
title="Delete Entry"
message="Are you sure you want to delete this time entry?"
onConfirm={handleDeleteConfirmed}
onClose={() => setConfirmEntry(null)}
/>
)}
</div> </div>
); );
} }

View File

@@ -3,6 +3,7 @@ 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 { Modal } from '@/components/Modal';
import { ConfirmModal } from '@/components/ConfirmModal';
import { Spinner } from '@/components/Spinner'; import { Spinner } from '@/components/Spinner';
import { ProjectColorDot } from '@/components/ProjectColorDot'; import { ProjectColorDot } from '@/components/ProjectColorDot';
import type { Project, CreateProjectInput, UpdateProjectInput } from '@/types'; import type { Project, CreateProjectInput, UpdateProjectInput } from '@/types';
@@ -87,13 +88,12 @@ export function ProjectsPage() {
} }
}; };
const handleDelete = async (project: Project) => { const [confirmProject, setConfirmProject] = useState<Project | null>(null);
if (!confirm(`Are you sure you want to delete "${project.name}"?`)) {
return;
}
const handleDeleteConfirmed = async () => {
if (!confirmProject) return;
try { try {
await deleteProject.mutateAsync(project.id); await deleteProject.mutateAsync(confirmProject.id);
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete project'); alert(err instanceof Error ? err.message : 'Failed to delete project');
} }
@@ -142,7 +142,7 @@ export function ProjectsPage() {
<button onClick={() => handleOpenModal(project)} className="p-1.5 text-gray-400 hover:text-gray-600 rounded"> <button onClick={() => handleOpenModal(project)} className="p-1.5 text-gray-400 hover:text-gray-600 rounded">
<Edit2 className="h-4 w-4" /> <Edit2 className="h-4 w-4" />
</button> </button>
<button onClick={() => handleDelete(project)} className="p-1.5 text-gray-400 hover:text-red-600 rounded"> <button onClick={() => setConfirmProject(project)} className="p-1.5 text-gray-400 hover:text-red-600 rounded">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
</div> </div>
@@ -199,6 +199,14 @@ export function ProjectsPage() {
</form> </form>
</Modal> </Modal>
)} )}
{confirmProject && (
<ConfirmModal
title={`Delete "${confirmProject.name}"`}
message="Are you sure you want to delete this project?"
onConfirm={handleDeleteConfirmed}
onClose={() => setConfirmProject(null)}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,88 +1,34 @@
import { useState } from 'react'; 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 { Modal } from '@/components/Modal';
import { Spinner } from '@/components/Spinner'; import { Spinner } from '@/components/Spinner';
import { ProjectColorDot } from '@/components/ProjectColorDot'; import { ProjectColorDot } from '@/components/ProjectColorDot';
import { formatDate, formatTime, formatDurationFromDatesHoursMinutes, getLocalISOString, toISOTimezone } from '@/utils/dateUtils'; import { TimeEntryFormModal } from '@/components/TimeEntryFormModal';
import type { TimeEntry, CreateTimeEntryInput, UpdateTimeEntryInput } from '@/types'; import { ConfirmModal } from '@/components/ConfirmModal';
import { formatDate, formatTime, formatDurationFromDatesHoursMinutes } from '@/utils/dateUtils';
import type { TimeEntry } from '@/types';
export function TimeEntriesPage() { export function TimeEntriesPage() {
const { data, isLoading, createTimeEntry, updateTimeEntry, deleteTimeEntry } = useTimeEntries(); const { data, isLoading, createTimeEntry, updateTimeEntry, deleteTimeEntry } = useTimeEntries();
const { projects } = useProjects();
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null); const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
const [formData, setFormData] = useState<CreateTimeEntryInput>({ const [confirmEntry, setConfirmEntry] = useState<TimeEntry | null>(null);
startTime: '',
endTime: '',
description: '',
projectId: '',
});
const [error, setError] = useState<string | null>(null);
const handleOpenModal = (entry?: TimeEntry) => { const handleOpenModal = (entry?: TimeEntry) => {
if (entry) { setEditingEntry(entry ?? null);
setEditingEntry(entry);
setFormData({
startTime: getLocalISOString(new Date(entry.startTime)),
endTime: getLocalISOString(new Date(entry.endTime)),
description: entry.description || '',
projectId: entry.projectId,
});
} else {
setEditingEntry(null);
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
setFormData({
startTime: getLocalISOString(oneHourAgo),
endTime: getLocalISOString(now),
description: '',
projectId: projects?.[0]?.id || '',
});
}
setError(null);
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleCloseModal = () => { const handleCloseModal = () => {
setIsModalOpen(false); setIsModalOpen(false);
setEditingEntry(null); setEditingEntry(null);
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!formData.projectId) {
setError('Please select a project');
return;
}
const input = {
...formData,
startTime: toISOTimezone(formData.startTime),
endTime: toISOTimezone(formData.endTime),
}; };
const handleDeleteConfirmed = async () => {
if (!confirmEntry) return;
try { try {
if (editingEntry) { await deleteTimeEntry.mutateAsync(confirmEntry.id);
await updateTimeEntry.mutateAsync({ id: editingEntry.id, input: input as UpdateTimeEntryInput });
} else {
await createTimeEntry.mutateAsync(input);
}
handleCloseModal();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
}
};
const handleDelete = async (entry: TimeEntry) => {
if (!confirm('Delete this time entry?')) return;
try {
await deleteTimeEntry.mutateAsync(entry.id);
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete'); alert(err instanceof Error ? err.message : 'Failed to delete');
} }
@@ -105,6 +51,7 @@ export function TimeEntriesPage() {
</div> </div>
<div className="card overflow-hidden"> <div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
@@ -130,63 +77,39 @@ export function TimeEntriesPage() {
</div> </div>
</div> </div>
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900">{formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime)}</td> <td className="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900">
{formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-right"> <td className="px-4 py-3 whitespace-nowrap text-right">
<button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button> <button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button>
<button onClick={() => handleDelete(entry)} className="p-1.5 text-gray-400 hover:text-red-600"><Trash2 className="h-4 w-4" /></button> <button onClick={() => setConfirmEntry(entry)} className="p-1.5 text-gray-400 hover:text-red-600"><Trash2 className="h-4 w-4" /></button>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div>
{data?.entries.length === 0 && ( {data?.entries.length === 0 && (
<div className="text-center py-8 text-gray-500">No time entries yet</div> <div className="text-center py-8 text-gray-500">No time entries yet</div>
)} )}
</div> </div>
{isModalOpen && ( {isModalOpen && (
<Modal <TimeEntryFormModal
title={editingEntry ? 'Edit Entry' : 'Add Entry'} entry={editingEntry}
onClose={handleCloseModal} onClose={handleCloseModal}
> createTimeEntry={createTimeEntry}
{error && <div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>} updateTimeEntry={updateTimeEntry}
<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"> {confirmEntry && (
{projects?.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)} <ConfirmModal
</select> title="Delete Entry"
</div> message="Are you sure you want to delete this time entry?"
<div className="grid grid-cols-2 gap-4"> onConfirm={handleDeleteConfirmed}
<div> onClose={() => setConfirmEntry(null)}
<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>
<label className="label">Description</label>
<textarea value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} className="input" rows={2} />
</div>
<div className="flex justify-end space-x-3 pt-2">
<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>
); );