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';
|
||||
|
||||
interface ModalProps {
|
||||
@@ -10,6 +11,7 @@ interface ModalProps {
|
||||
|
||||
/**
|
||||
* Generic modal overlay with a header and close button.
|
||||
* Closes on Escape key or backdrop click.
|
||||
* Render form content (or any JSX) as children.
|
||||
*
|
||||
* @example
|
||||
@@ -18,8 +20,19 @@ interface ModalProps {
|
||||
* </Modal>
|
||||
*/
|
||||
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 (
|
||||
<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="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>
|
||||
|
||||
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 (
|
||||
<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 ? (
|
||||
<>
|
||||
{/* Running Timer Display */}
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Row 1 (mobile): timer + stop side by side. On sm+ dissolves into the parent flex row via contents. */}
|
||||
<div className="flex items-center justify-between w-full sm:contents">
|
||||
{/* 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>
|
||||
<TimerDisplay totalSeconds={elapsedSeconds} />
|
||||
</div>
|
||||
|
||||
{/* Project Selector */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowProjectSelect(!showProjectSelect)}
|
||||
className="flex items-center space-x-2 px-3 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
{/* 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 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
|
||||
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 ? (
|
||||
<>
|
||||
<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}
|
||||
</span>
|
||||
</>
|
||||
@@ -118,50 +130,41 @@ export function TimerWidget() {
|
||||
Select project...
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-gray-500 shrink-0" />
|
||||
</button>
|
||||
|
||||
{showProjectSelect && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 max-h-64 overflow-y-auto">
|
||||
{showProjectSelect && (
|
||||
<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
|
||||
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"
|
||||
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"
|
||||
>
|
||||
No project
|
||||
</button>
|
||||
{projects?.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => handleProjectChange(project.id)}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center space-x-2"
|
||||
>
|
||||
<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>
|
||||
<ProjectColorDot color={project.color} />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-900 truncate">
|
||||
{project.name}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{project.client.name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</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 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<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" />
|
||||
<span>Start</span>
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user