Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
add07a8450
commit
18545bc093
@@ -0,0 +1,249 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTimer, formatHMS } from "@/lib/timer";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
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 {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Pause, Play, Square, Timer as TimerIcon, Loader2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ClientOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
interface CaseOption {
|
||||
id: string;
|
||||
case_number: string;
|
||||
title: string;
|
||||
client_id: string;
|
||||
default_hourly_rate: number | null;
|
||||
}
|
||||
|
||||
export function HeaderTimer() {
|
||||
const { user } = useAuth();
|
||||
const { timer, elapsedMs, isRunning, hasStarted, setClient, setCase, setDescription, start, pause, resume, reset, consume } = useTimer();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
const [cases, setCases] = useState<CaseOption[]>([]);
|
||||
const [profileRate, setProfileRate] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [billable, setBillable] = useState(true);
|
||||
|
||||
// Load clients + cases the user can access (RLS handles filtering)
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
(async () => {
|
||||
const [{ data: cs }, { data: ks }, { data: prof }] = await Promise.all([
|
||||
supabase.from("clients").select("id, name").order("name"),
|
||||
supabase
|
||||
.from("cases")
|
||||
.select("id, case_number, title, client_id, default_hourly_rate")
|
||||
.order("case_number", { ascending: false }),
|
||||
supabase.from("profiles").select("hourly_rate").eq("id", user.id).maybeSingle(),
|
||||
]);
|
||||
setClients(cs ?? []);
|
||||
setCases(ks ?? []);
|
||||
setProfileRate((prof as any)?.hourly_rate ?? null);
|
||||
})();
|
||||
}, [user?.id]);
|
||||
|
||||
const filteredCases = useMemo(
|
||||
() => (timer.clientId ? cases.filter((c) => c.client_id === timer.clientId) : []),
|
||||
[cases, timer.clientId],
|
||||
);
|
||||
|
||||
const activeCase = useMemo(
|
||||
() => cases.find((c) => c.id === timer.caseId),
|
||||
[cases, timer.caseId],
|
||||
);
|
||||
|
||||
const effectiveRate = activeCase?.default_hourly_rate ?? profileRate ?? 0;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!user?.id) return toast.error("Not signed in");
|
||||
if (!timer.caseId) return toast.error("Select a case before saving");
|
||||
if (!timer.description.trim()) return toast.error("Description required");
|
||||
if (elapsedMs < 1000) return toast.error("Timer hasn't recorded any time yet");
|
||||
setSaving(true);
|
||||
const { hours } = consume();
|
||||
const { error } = await supabase.from("time_entries").insert({
|
||||
case_id: timer.caseId,
|
||||
user_id: user.id,
|
||||
work_date: new Date().toISOString().slice(0, 10),
|
||||
hours,
|
||||
hourly_rate: effectiveRate,
|
||||
description: timer.description.trim(),
|
||||
billable,
|
||||
});
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
toast.success(`Saved ${hours.toFixed(1)} hr`, {
|
||||
description: `${formatCurrency(hours * effectiveRate)} at ${formatCurrency(effectiveRate)}/hr`,
|
||||
});
|
||||
setBillable(true);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (hasStarted && !confirm("Discard the running timer?")) return;
|
||||
reset();
|
||||
setBillable(true);
|
||||
};
|
||||
|
||||
const dotClass = isRunning
|
||||
? "bg-emerald-500 animate-pulse"
|
||||
: hasStarted
|
||||
? "bg-amber-500"
|
||||
: "bg-muted-foreground/40";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 gap-2 font-mono tabular-nums"
|
||||
aria-label="Open timer"
|
||||
>
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${dotClass}`} />
|
||||
<TimerIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{formatHMS(elapsedMs)}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[360px] p-0">
|
||||
<div className="px-4 py-3 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground">Time tracker</div>
|
||||
<div className="font-mono text-3xl tabular-nums leading-tight mt-0.5">
|
||||
{formatHMS(elapsedMs)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isRunning ? (
|
||||
<Button size="icon" variant="secondary" onClick={pause} aria-label="Pause">
|
||||
<Pause className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="icon" onClick={hasStarted ? resume : start} aria-label="Start">
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasStarted && (
|
||||
<Button size="icon" variant="ghost" onClick={handleDiscard} aria-label="Discard">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Client</Label>
|
||||
<Select
|
||||
value={timer.clientId ?? ""}
|
||||
onValueChange={(v) => setClient(v || null)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Case</Label>
|
||||
<Select
|
||||
value={timer.caseId ?? ""}
|
||||
onValueChange={(v) => setCase(v || null)}
|
||||
disabled={!timer.clientId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={timer.clientId ? "Select case" : "Pick a client first"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredCases.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No cases for this client.</div>
|
||||
) : (
|
||||
filteredCases.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span className="text-muted-foreground mr-2">{c.case_number}</span>
|
||||
{c.title}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder="What are you working on?"
|
||||
value={timer.description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={1000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 pt-1">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">Rate</div>
|
||||
<div className="text-sm font-medium mt-0.5">
|
||||
{effectiveRate ? `${formatCurrency(effectiveRate)}/hr` : "—"}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{activeCase?.default_hourly_rate ? "From case" : profileRate ? "Your default" : "Set rate in profile"}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-end gap-2 text-sm pb-1">
|
||||
<Checkbox checked={billable} onCheckedChange={(c) => setBillable(!!c)} />
|
||||
Billable
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Time saves rounded up to the nearest 1/10 hour (6 minutes).
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !timer.caseId || !timer.description.trim() || elapsedMs < 1000}
|
||||
>
|
||||
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
Stop & save
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCurrency(n: number): string {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n || 0);
|
||||
}
|
||||
Reference in New Issue
Block a user