Added top-bar timer & rates
X-Lovable-Edit-ID: edt-b9558a6a-4be2-4f29-aea7-5440dcb0b52d Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HeaderTimer } from "@/components/timer/header-timer";
|
||||
import {
|
||||
Briefcase,
|
||||
Users,
|
||||
@@ -98,17 +99,24 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</aside>
|
||||
|
||||
{/* Mobile top bar */}
|
||||
<div className="md:hidden fixed top-0 inset-x-0 h-14 bg-sidebar text-sidebar-foreground border-b border-sidebar-border flex items-center justify-between px-4 z-30">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<Scale className="h-5 w-5" />
|
||||
<span className="font-serif text-lg">Counsel</span>
|
||||
<div className="md:hidden fixed top-0 inset-x-0 h-14 bg-sidebar text-sidebar-foreground border-b border-sidebar-border flex items-center justify-between px-4 z-30 gap-2">
|
||||
<Link to="/" className="flex items-center gap-2 min-w-0">
|
||||
<Scale className="h-5 w-5 shrink-0" />
|
||||
<span className="font-serif text-lg truncate">Counsel</span>
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={handleSignOut} className="text-sidebar-foreground">
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<HeaderTimer />
|
||||
<Button variant="ghost" size="sm" onClick={handleSignOut} className="text-sidebar-foreground">
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 min-w-0 md:ml-0 mt-14 md:mt-0">
|
||||
{/* Desktop top bar with timer */}
|
||||
<div className="hidden md:flex h-14 items-center justify-end gap-3 px-6 border-b bg-card">
|
||||
<HeaderTimer />
|
||||
</div>
|
||||
<div className="md:hidden flex overflow-x-auto gap-1 px-3 py-2 border-b bg-card">
|
||||
{NAV.filter((n) => !n.adminOnly || isAdmin).map((item) => {
|
||||
const active =
|
||||
|
||||
@@ -9,6 +9,7 @@ import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Plus, Trash2, Loader2 } from "lucide-react";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { roundToTenth } from "@/lib/timer";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function CaseTimeTab({ caseRecord }: { caseRecord: any }) {
|
||||
@@ -16,6 +17,7 @@ export function CaseTimeTab({ caseRecord }: { caseRecord: any }) {
|
||||
const [entries, setEntries] = useState<any[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [profileRate, setProfileRate] = useState<number | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
work_date: new Date().toISOString().slice(0, 10),
|
||||
hours: "",
|
||||
@@ -35,9 +37,23 @@ export function CaseTimeTab({ caseRecord }: { caseRecord: any }) {
|
||||
|
||||
useEffect(() => { load(); }, [caseRecord.id]);
|
||||
|
||||
// Pull this user's default hourly rate; use it to prefill when case has no override.
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
(async () => {
|
||||
const { data } = await supabase.from("profiles").select("hourly_rate").eq("id", user.id).maybeSingle();
|
||||
const rate = (data as any)?.hourly_rate ?? null;
|
||||
setProfileRate(rate);
|
||||
if (!caseRecord.default_hourly_rate && rate != null) {
|
||||
setForm((f) => (f.hourly_rate ? f : { ...f, hourly_rate: String(rate) }));
|
||||
}
|
||||
})();
|
||||
}, [user?.id, caseRecord.default_hourly_rate]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const hours = parseFloat(form.hours);
|
||||
const rawHours = parseFloat(form.hours);
|
||||
const hours = roundToTenth(rawHours);
|
||||
const rate = parseFloat(form.hourly_rate || "0");
|
||||
if (!hours || hours <= 0) { toast.error("Hours must be greater than 0"); return; }
|
||||
if (!form.description.trim()) { toast.error("Description required"); return; }
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -378,6 +378,7 @@ export type Database = {
|
||||
created_at: string
|
||||
email: string
|
||||
full_name: string
|
||||
hourly_rate: number | null
|
||||
id: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -385,6 +386,7 @@ export type Database = {
|
||||
created_at?: string
|
||||
email?: string
|
||||
full_name?: string
|
||||
hourly_rate?: number | null
|
||||
id: string
|
||||
updated_at?: string
|
||||
}
|
||||
@@ -392,6 +394,7 @@ export type Database = {
|
||||
created_at?: string
|
||||
email?: string
|
||||
full_name?: string
|
||||
hourly_rate?: number | null
|
||||
id?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
+80
-83
@@ -2,171 +2,162 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode
|
||||
|
||||
const STORAGE_KEY = "counsel.timer.v1";
|
||||
|
||||
export interface TimerTarget {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
caseId?: string | null;
|
||||
caseNumber?: string | null;
|
||||
caseTitle?: string | null;
|
||||
defaultHourlyRate?: number | null;
|
||||
}
|
||||
|
||||
interface PersistedTimer extends TimerTarget {
|
||||
interface PersistedTimer {
|
||||
clientId: string | null;
|
||||
caseId: string | null;
|
||||
description: string;
|
||||
// Total accumulated milliseconds while running, excluding current segment
|
||||
accumulatedMs: number;
|
||||
// Timestamp (ms) when the current running segment started, or null if paused
|
||||
runningSince: number | null;
|
||||
description: string;
|
||||
startedAt: string; // ISO when first started
|
||||
startedAt: string | null;
|
||||
}
|
||||
|
||||
interface TimerState {
|
||||
active: PersistedTimer | null;
|
||||
timer: PersistedTimer;
|
||||
elapsedMs: number;
|
||||
isRunning: boolean;
|
||||
start: (target: TimerTarget, description?: string) => void;
|
||||
hasStarted: boolean;
|
||||
setClient: (clientId: string | null) => void;
|
||||
setCase: (caseId: string | null) => void;
|
||||
setDescription: (description: string) => void;
|
||||
start: () => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
reset: () => void;
|
||||
setDescription: (d: string) => void;
|
||||
// Returns elapsed hours rounded to 2 decimals; clears timer
|
||||
stopAndConsume: () => { hours: number; description: string; target: TimerTarget; startedAt: string } | null;
|
||||
consume: () => { hours: number };
|
||||
}
|
||||
|
||||
const empty: PersistedTimer = {
|
||||
clientId: null,
|
||||
caseId: null,
|
||||
description: "",
|
||||
accumulatedMs: 0,
|
||||
runningSince: null,
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
const TimerContext = createContext<TimerState | undefined>(undefined);
|
||||
|
||||
function read(): PersistedTimer | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
function read(): PersistedTimer {
|
||||
if (typeof window === "undefined") return empty;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as PersistedTimer;
|
||||
if (!raw) return empty;
|
||||
return { ...empty, ...(JSON.parse(raw) as PersistedTimer) };
|
||||
} catch {
|
||||
return null;
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
function write(t: PersistedTimer | null) {
|
||||
function write(t: PersistedTimer) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!t) localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
|
||||
if (t.accumulatedMs === 0 && !t.runningSince && !t.clientId && !t.caseId && !t.description) {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} else {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
|
||||
}
|
||||
}
|
||||
|
||||
function computeElapsed(t: PersistedTimer | null): number {
|
||||
if (!t) return 0;
|
||||
function computeElapsed(t: PersistedTimer): number {
|
||||
return t.accumulatedMs + (t.runningSince ? Date.now() - t.runningSince : 0);
|
||||
}
|
||||
|
||||
export function TimerProvider({ children }: { children: ReactNode }) {
|
||||
const [active, setActive] = useState<PersistedTimer | null>(null);
|
||||
const [timer, setTimer] = useState<PersistedTimer>(empty);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
// Hydrate from localStorage on mount
|
||||
useEffect(() => {
|
||||
const t = read();
|
||||
if (t) {
|
||||
setActive(t);
|
||||
setElapsedMs(computeElapsed(t));
|
||||
}
|
||||
setTimer(t);
|
||||
setElapsedMs(computeElapsed(t));
|
||||
}, []);
|
||||
|
||||
// Tick every second when running
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (active?.runningSince) {
|
||||
if (timer.runningSince) {
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setElapsedMs(computeElapsed(active));
|
||||
setElapsedMs(computeElapsed(timer));
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [active]);
|
||||
}, [timer]);
|
||||
|
||||
// Cross-tab sync
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== STORAGE_KEY) return;
|
||||
const t = read();
|
||||
setActive(t);
|
||||
setTimer(t);
|
||||
setElapsedMs(computeElapsed(t));
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, []);
|
||||
|
||||
const persist = (t: PersistedTimer | null) => {
|
||||
const persist = (t: PersistedTimer) => {
|
||||
write(t);
|
||||
setActive(t);
|
||||
setTimer(t);
|
||||
setElapsedMs(computeElapsed(t));
|
||||
};
|
||||
|
||||
const start: TimerState["start"] = (target, description = "") => {
|
||||
const t: PersistedTimer = {
|
||||
...target,
|
||||
accumulatedMs: 0,
|
||||
const setClient = (clientId: string | null) =>
|
||||
persist({ ...timer, clientId, caseId: clientId === timer.clientId ? timer.caseId : null });
|
||||
const setCase = (caseId: string | null) => persist({ ...timer, caseId });
|
||||
const setDescription = (description: string) => persist({ ...timer, description });
|
||||
|
||||
const start = () => {
|
||||
if (timer.runningSince) return;
|
||||
persist({
|
||||
...timer,
|
||||
runningSince: Date.now(),
|
||||
description,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
persist(t);
|
||||
startedAt: timer.startedAt ?? new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
if (!active?.runningSince) return;
|
||||
const accumulatedMs = active.accumulatedMs + (Date.now() - active.runningSince);
|
||||
persist({ ...active, accumulatedMs, runningSince: null });
|
||||
if (!timer.runningSince) return;
|
||||
const accumulatedMs = timer.accumulatedMs + (Date.now() - timer.runningSince);
|
||||
persist({ ...timer, accumulatedMs, runningSince: null });
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
if (!active || active.runningSince) return;
|
||||
persist({ ...active, runningSince: Date.now() });
|
||||
if (timer.runningSince) return;
|
||||
persist({ ...timer, runningSince: Date.now() });
|
||||
};
|
||||
|
||||
const reset = () => persist(null);
|
||||
const reset = () => persist(empty);
|
||||
|
||||
const setDescription = (description: string) => {
|
||||
if (!active) return;
|
||||
persist({ ...active, description });
|
||||
};
|
||||
|
||||
const stopAndConsume: TimerState["stopAndConsume"] = () => {
|
||||
if (!active) return null;
|
||||
const ms = computeElapsed(active);
|
||||
const hours = Math.max(0.01, Math.round((ms / 3600000) * 100) / 100);
|
||||
const result = {
|
||||
hours,
|
||||
description: active.description,
|
||||
target: {
|
||||
clientId: active.clientId,
|
||||
clientName: active.clientName,
|
||||
caseId: active.caseId,
|
||||
caseNumber: active.caseNumber,
|
||||
caseTitle: active.caseTitle,
|
||||
defaultHourlyRate: active.defaultHourlyRate,
|
||||
},
|
||||
startedAt: active.startedAt,
|
||||
};
|
||||
persist(null);
|
||||
return result;
|
||||
const consume = () => {
|
||||
const ms = computeElapsed(timer);
|
||||
// Round UP to the nearest 0.1 hour (6 minute / 360,000 ms increments).
|
||||
const increment = 6 * 60 * 1000;
|
||||
const rounded = Math.max(increment, Math.ceil(ms / increment) * increment);
|
||||
const hours = Math.round((rounded / 3600000) * 10) / 10;
|
||||
persist(empty);
|
||||
return { hours };
|
||||
};
|
||||
|
||||
return (
|
||||
<TimerContext.Provider
|
||||
value={{
|
||||
active,
|
||||
timer,
|
||||
elapsedMs,
|
||||
isRunning: !!active?.runningSince,
|
||||
isRunning: !!timer.runningSince,
|
||||
hasStarted: timer.accumulatedMs > 0 || !!timer.runningSince,
|
||||
setClient,
|
||||
setCase,
|
||||
setDescription,
|
||||
start,
|
||||
pause,
|
||||
resume,
|
||||
reset,
|
||||
setDescription,
|
||||
stopAndConsume,
|
||||
consume,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -187,3 +178,9 @@ export function formatHMS(ms: number): string {
|
||||
const s = total % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Round hours UP to the nearest 0.1 (6-minute) increment, with 0.1 minimum. */
|
||||
export function roundToTenth(hours: number): number {
|
||||
if (!hours || hours <= 0) return 0;
|
||||
return Math.max(0.1, Math.round(Math.ceil(hours * 10)) / 10);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ interface UserRow {
|
||||
email: string;
|
||||
full_name: string;
|
||||
created_at: string;
|
||||
hourly_rate: number | null;
|
||||
role: AppRole | null;
|
||||
}
|
||||
|
||||
@@ -84,16 +85,17 @@ function UsersPage() {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const [{ data: profiles }, { data: rolesData }] = await Promise.all([
|
||||
supabase.from("profiles").select("id, email, full_name, created_at"),
|
||||
supabase.from("profiles").select("id, email, full_name, created_at, hourly_rate"),
|
||||
supabase.from("user_roles").select("user_id, role"),
|
||||
]);
|
||||
const roleMap = new Map<string, AppRole>();
|
||||
(rolesData ?? []).forEach((r) => roleMap.set(r.user_id, r.role as AppRole));
|
||||
const rows: UserRow[] = (profiles ?? []).map((p) => ({
|
||||
const rows: UserRow[] = (profiles ?? []).map((p: any) => ({
|
||||
id: p.id,
|
||||
email: p.email,
|
||||
full_name: p.full_name,
|
||||
created_at: p.created_at,
|
||||
hourly_rate: p.hourly_rate,
|
||||
role: roleMap.get(p.id) ?? null,
|
||||
}));
|
||||
rows.sort((a, b) => a.email.localeCompare(b.email));
|
||||
@@ -129,6 +131,19 @@ function UsersPage() {
|
||||
load();
|
||||
};
|
||||
|
||||
const handleRateSave = async (userId: string, rate: number | null) => {
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({ hourly_rate: rate })
|
||||
.eq("id", userId);
|
||||
if (error) {
|
||||
toast.error("Could not save rate", { description: error.message });
|
||||
return;
|
||||
}
|
||||
toast.success("Rate updated");
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
@@ -171,6 +186,7 @@ function UsersPage() {
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead className="w-[140px]">Hourly rate</TableHead>
|
||||
<TableHead className="w-[80px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -206,6 +222,12 @@ function UsersPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RateInput
|
||||
value={u.hourly_rate}
|
||||
onSave={(v) => handleRateSave(u.id, v)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!isSelf && (
|
||||
<AlertDialog>
|
||||
@@ -243,6 +265,51 @@ function UsersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function RateInput({
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
value: number | null;
|
||||
onSave: (v: number | null) => void | Promise<void>;
|
||||
}) {
|
||||
const [text, setText] = useState(value != null ? String(value) : "");
|
||||
useEffect(() => setText(value != null ? String(value) : ""), [value]);
|
||||
const dirty = text !== (value != null ? String(value) : "");
|
||||
|
||||
const commit = () => {
|
||||
if (!dirty) return;
|
||||
if (text.trim() === "") return onSave(null);
|
||||
const n = parseFloat(text);
|
||||
if (Number.isNaN(n) || n < 0) {
|
||||
toast.error("Invalid rate");
|
||||
setText(value != null ? String(value) : "");
|
||||
return;
|
||||
}
|
||||
onSave(n);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">$</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
}}
|
||||
placeholder="—"
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">/hr</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function InviteDialog({ onCreated }: { onCreated: () => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [fullName, setFullName] = useState("");
|
||||
|
||||
@@ -18,7 +18,6 @@ import { CaseInvoicesTab } from "@/components/cases/invoices-tab";
|
||||
import { CaseLitigationTab } from "@/components/cases/litigation-tab";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { StartTimerButton } from "@/components/timer/start-timer-button";
|
||||
|
||||
export const Route = createFileRoute("/cases/$caseId")({
|
||||
component: () => (
|
||||
@@ -106,41 +105,27 @@ function CaseDetail() {
|
||||
{" · "}{data.practice_area || "—"}{" · "}opened {formatDate(data.opened_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StartTimerButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
target={{
|
||||
clientId: data.client.id,
|
||||
clientName: data.client.name,
|
||||
caseId: data.id,
|
||||
caseNumber: data.case_number,
|
||||
caseTitle: data.title,
|
||||
defaultHourlyRate: data.default_hourly_rate,
|
||||
}}
|
||||
/>
|
||||
{canManage && (
|
||||
<>
|
||||
<Select value={data.assigned_attorney_id ?? ""} onValueChange={updateAssignee}>
|
||||
<SelectTrigger className="w-[200px]"><SelectValue placeholder="Assign attorney" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((u) => <SelectItem key={u.id} value={u.id}>{u.full_name || u.email}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={data.status} onValueChange={updateStatus}>
|
||||
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="intake">Intake</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="on_hold">On hold</SelectItem>
|
||||
<SelectItem value="closed_won">Closed — won</SelectItem>
|
||||
<SelectItem value="closed_lost">Closed — lost</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={data.assigned_attorney_id ?? ""} onValueChange={updateAssignee}>
|
||||
<SelectTrigger className="w-[200px]"><SelectValue placeholder="Assign attorney" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((u) => <SelectItem key={u.id} value={u.id}>{u.full_name || u.email}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={data.status} onValueChange={updateStatus}>
|
||||
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="intake">Intake</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="on_hold">On hold</SelectItem>
|
||||
<SelectItem value="closed_won">Closed — won</SelectItem>
|
||||
<SelectItem value="closed_lost">Closed — lost</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.description && (
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ClientFormDialog } from "@/components/clients/client-form-dialog";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { ArrowLeft, Edit, Plus, Building2, User, Briefcase as Building, MapPin, Mail, Phone, Users } from "lucide-react";
|
||||
import { formatDate, statusBadgeClass } from "@/lib/format";
|
||||
import { StartTimerButton } from "@/components/timer/start-timer-button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/clients/$clientId")({
|
||||
@@ -80,11 +79,6 @@ function ClientDetail() {
|
||||
description={client.management_company || client.client_type.toUpperCase()}
|
||||
actions={
|
||||
<>
|
||||
<StartTimerButton
|
||||
variant="outline"
|
||||
target={{ clientId: client.id, clientName: client.name }}
|
||||
label="Start client timer"
|
||||
/>
|
||||
{canEdit && (
|
||||
<Button variant="outline" onClick={() => setEditOpen(true)}>
|
||||
<Edit className="h-4 w-4 mr-2" /> Edit
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS hourly_rate numeric;
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP POLICY IF EXISTS profiles_update_admin ON public.profiles;
|
||||
CREATE POLICY profiles_update_admin ON public.profiles
|
||||
FOR UPDATE TO authenticated
|
||||
USING (public.is_admin(auth.uid()))
|
||||
WITH CHECK (public.is_admin(auth.uid()));
|
||||
Reference in New Issue
Block a user