Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
18545bc093
commit
225292af7f
@@ -2,7 +2,6 @@ import { useEffect, type ReactNode } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { TimerWidget } from "@/components/timer/timer-widget";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function ProtectedLayout({ children, adminOnly }: { children: ReactNode; adminOnly?: boolean }) {
|
||||
@@ -26,10 +25,5 @@ export function ProtectedLayout({ children, adminOnly }: { children: ReactNode;
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
{children}
|
||||
<TimerWidget />
|
||||
</AppShell>
|
||||
);
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||
import { useTimer, type TimerTarget } from "@/lib/timer";
|
||||
import { Play, Timer as TimerIcon } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { formatHMS } from "@/lib/timer";
|
||||
|
||||
interface Props extends Omit<ButtonProps, "onClick"> {
|
||||
target: TimerTarget;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function StartTimerButton({ target, label = "Start timer", ...rest }: Props) {
|
||||
const { active, elapsedMs, start } = useTimer();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (active) setConfirmOpen(true);
|
||||
else start(target);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button {...rest} onClick={handleClick}>
|
||||
<Play className="h-4 w-4 mr-1.5" />
|
||||
{label}
|
||||
</Button>
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<TimerIcon className="h-4 w-4" /> Replace running timer?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
A timer is already running ({formatHMS(elapsedMs)}) on{" "}
|
||||
<span className="font-medium">{active?.caseNumber ?? active?.clientName}</span>. Starting a new
|
||||
one will discard it without saving.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep current</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => start(target)}>Discard & start new</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTimer, formatHMS } from "@/lib/timer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Pause, Play, Square, Timer as TimerIcon, Loader2 } from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { toast } from "sonner";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export function TimerWidget() {
|
||||
const { active, elapsedMs, isRunning, pause, resume, setDescription, stopAndConsume, reset } = useTimer();
|
||||
const { user, session } = useAuth();
|
||||
const [stopOpen, setStopOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({ hours: "", rate: "", description: "", billable: true, work_date: "" });
|
||||
|
||||
if (!session || !active) return null;
|
||||
|
||||
const openStop = () => {
|
||||
if (isRunning) pause();
|
||||
const snapshot = {
|
||||
hours: (elapsedMs / 3600000).toFixed(2),
|
||||
rate: active.defaultHourlyRate?.toString() ?? "",
|
||||
description: active.description,
|
||||
billable: true,
|
||||
work_date: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
setForm(snapshot);
|
||||
setStopOpen(true);
|
||||
};
|
||||
|
||||
const cancelStop = () => setStopOpen(false);
|
||||
|
||||
const discard = () => {
|
||||
if (!confirm("Discard this timer without saving?")) return;
|
||||
reset();
|
||||
setStopOpen(false);
|
||||
};
|
||||
|
||||
const saveEntry = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user?.id) return;
|
||||
if (!active.caseId) {
|
||||
toast.error("Attach the timer to a case before saving a time entry.");
|
||||
return;
|
||||
}
|
||||
const hours = parseFloat(form.hours);
|
||||
const rate = parseFloat(form.rate || "0");
|
||||
if (!hours || hours <= 0) return toast.error("Hours must be greater than 0");
|
||||
if (!form.description.trim()) return toast.error("Description required");
|
||||
setSaving(true);
|
||||
const { error } = await supabase.from("time_entries").insert({
|
||||
case_id: active.caseId,
|
||||
user_id: user.id,
|
||||
work_date: form.work_date,
|
||||
hours,
|
||||
hourly_rate: rate,
|
||||
description: form.description.trim(),
|
||||
billable: form.billable,
|
||||
});
|
||||
setSaving(false);
|
||||
if (error) return toast.error(error.message);
|
||||
toast.success("Time entry saved");
|
||||
stopAndConsume();
|
||||
setStopOpen(false);
|
||||
};
|
||||
|
||||
const subtitle = active.caseId
|
||||
? `${active.caseNumber ?? ""} · ${active.caseTitle ?? ""}`
|
||||
: `${active.clientName} (no case)`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-4 right-4 z-50 w-[320px] rounded-lg border bg-card text-card-foreground shadow-lg">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b">
|
||||
<TimerIcon className="h-4 w-4 text-primary" />
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">Active timer</span>
|
||||
<span className={`ml-auto inline-block h-2 w-2 rounded-full ${isRunning ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40"}`} />
|
||||
</div>
|
||||
<div className="px-3 pt-3 pb-2">
|
||||
<div className="font-mono text-3xl tabular-nums tracking-tight">{formatHMS(elapsedMs)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground truncate">
|
||||
{active.caseId ? (
|
||||
<Link to="/cases/$caseId" params={{ caseId: active.caseId }} className="hover:text-primary underline-offset-4 hover:underline">
|
||||
{subtitle}
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/clients/$clientId" params={{ clientId: active.clientId }} className="hover:text-primary underline-offset-4 hover:underline">
|
||||
{subtitle}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{!active.caseId && (
|
||||
<div className="mt-1 text-[10px] text-destructive">Attach to a case to save as a time entry.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 px-3 pb-3">
|
||||
{isRunning ? (
|
||||
<Button size="sm" variant="secondary" className="flex-1" onClick={pause}>
|
||||
<Pause className="h-4 w-4 mr-1.5" /> Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" className="flex-1" onClick={resume}>
|
||||
<Play className="h-4 w-4 mr-1.5" /> Resume
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" className="flex-1" onClick={openStop}>
|
||||
<Square className="h-4 w-4 mr-1.5" /> Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={stopOpen} onOpenChange={setStopOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save time entry</DialogTitle>
|
||||
<DialogDescription>{subtitle}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={saveEntry} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Date</Label>
|
||||
<Input type="date" value={form.work_date} onChange={(e) => setForm({ ...form, work_date: e.target.value })} required />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Hours</Label>
|
||||
<Input type="number" step="0.01" min="0.01" value={form.hours} onChange={(e) => setForm({ ...form, hours: e.target.value })} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Hourly rate ($)</Label>
|
||||
<Input type="number" step="0.01" min="0" value={form.rate} onChange={(e) => setForm({ ...form, rate: e.target.value })} required />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, description: e.target.value });
|
||||
setDescription(e.target.value);
|
||||
}}
|
||||
required
|
||||
maxLength={1000}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={form.billable} onCheckedChange={(c) => setForm({ ...form, billable: !!c })} />
|
||||
Billable
|
||||
</label>
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button type="button" variant="ghost" className="text-destructive" onClick={discard}>
|
||||
Discard
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={cancelStop}>Keep timing</Button>
|
||||
<Button type="submit" disabled={saving || !active.caseId}>
|
||||
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Save entry
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user