improvements
This commit is contained in:
35
frontend/src/components/ConfirmModal.tsx
Normal file
35
frontend/src/components/ConfirmModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
122
frontend/src/components/TimeEntryFormModal.tsx
Normal file
122
frontend/src/components/TimeEntryFormModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
<button
|
onClick={handleStop}
|
||||||
onClick={() => setShowProjectSelect(!showProjectSelect)}
|
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"
|
||||||
className="flex items-center space-x-2 px-3 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
>
|
||||||
>
|
<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
|
||||||
|
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 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,50 +130,41 @@ export function TimerWidget() {
|
|||||||
Select project...
|
Select project...
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
</span>
|
||||||
</button>
|
<ChevronDown className="h-4 w-4 text-gray-500 shrink-0" />
|
||||||
|
</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
|
||||||
|
onClick={handleClearProject}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-gray-500 hover:bg-gray-50 border-b border-gray-100"
|
||||||
|
>
|
||||||
|
No project
|
||||||
|
</button>
|
||||||
|
{projects?.map((project) => (
|
||||||
<button
|
<button
|
||||||
onClick={handleClearProject}
|
key={project.id}
|
||||||
className="w-full px-4 py-2 text-left text-sm text-gray-500 hover:bg-gray-50 border-b border-gray-100"
|
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"
|
||||||
>
|
>
|
||||||
No project
|
<ProjectColorDot color={project.color} />
|
||||||
</button>
|
<div className="min-w-0">
|
||||||
{projects?.map((project) => (
|
<div className="font-medium text-gray-900 truncate">
|
||||||
<button
|
{project.name}
|
||||||
key={project.id}
|
|
||||||
onClick={() => handleProjectChange(project.id)}
|
|
||||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
<ProjectColorDot color={project.color} />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="font-medium text-gray-900 truncate">
|
|
||||||
{project.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 truncate">
|
|
||||||
{project.client.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<div className="text-xs text-gray-500 truncate">
|
||||||
))}
|
{project.client.name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stop Button */}
|
|
||||||
<button
|
|
||||||
onClick={handleStop}
|
|
||||||
className="flex items-center space-x-2 px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Square className="h-5 w-5 fill-current" />
|
|
||||||
<span>Stop</span>
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
@@ -181,13 +211,17 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
||||||
<div>{formatDate(entry.startTime)}</div>
|
<div>{formatDate(entry.startTime)}</div>
|
||||||
<div className="text-xs text-gray-400">{formatTime(entry.startTime)} – {formatTime(entry.endTime)}</div>
|
<div className="text-xs text-gray-400">{formatTime(entry.startTime)} – {formatTime(entry.endTime)}</div>
|
||||||
</td>
|
</td>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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) => {
|
const handleDeleteConfirmed = async () => {
|
||||||
e.preventDefault();
|
if (!confirmEntry) return;
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (!formData.projectId) {
|
|
||||||
setError('Please select a project');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = {
|
|
||||||
...formData,
|
|
||||||
startTime: toISOTimezone(formData.startTime),
|
|
||||||
endTime: toISOTimezone(formData.endTime),
|
|
||||||
};
|
|
||||||
|
|
||||||
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,64 +77,40 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user