Polish mobile and web experience
This commit is contained in:
@@ -42,6 +42,7 @@ import {
|
||||
Mail,
|
||||
} from "lucide-react";
|
||||
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||
import { generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -108,7 +109,7 @@ function plainTextToHtml(value: string) {
|
||||
|
||||
function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||
return {
|
||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
||||
invoiceNumber: generateInvoiceNumber(),
|
||||
invoicePrefix: "#",
|
||||
businessId: "",
|
||||
clientId: "",
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
@@ -11,6 +18,7 @@ import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
@@ -20,7 +28,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "~/components/ui/collapsible";
|
||||
import { ChevronDown, Clock, Play, Square } from "lucide-react";
|
||||
import { ChevronDown, Play, Square } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
@@ -39,8 +47,6 @@ import { invoiceLabel } from "~/lib/time-entry-display";
|
||||
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
|
||||
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
|
||||
|
||||
const FEATURED_CLIENT_COUNT = 4;
|
||||
|
||||
type StartMode = "now" | "pick" | "ago";
|
||||
|
||||
function toDatetimeLocalValue(value: Date | string) {
|
||||
@@ -67,21 +73,25 @@ function RunningTextFields({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="clock-running-title">What are you working on?</Label>
|
||||
<Input
|
||||
id="clock-running-title"
|
||||
name="description"
|
||||
autoComplete="off"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={() => onDescriptionCommit(title)}
|
||||
placeholder="What are you working on?"
|
||||
placeholder="e.g. Client kickoff…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="clock-running-start">Started at</Label>
|
||||
<Input
|
||||
id="clock-running-start"
|
||||
name="startedAt"
|
||||
autoComplete="off"
|
||||
type="datetime-local"
|
||||
value={runningStartedAt}
|
||||
onChange={(e) => {
|
||||
@@ -105,31 +115,6 @@ export type TimeClockPanelProps = {
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
function ClientChip({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeClockPanel({
|
||||
defaultClientId = "",
|
||||
defaultInvoiceId = "",
|
||||
@@ -161,7 +146,6 @@ export function TimeClockPanel({
|
||||
const [stopNote, setStopNote] = useState("");
|
||||
const [rate, setRate] = useState(0);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [showAllClients, setShowAllClients] = useState(false);
|
||||
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||
const [startMode, setStartMode] = useState<StartMode>("now");
|
||||
const [pickedStart, setPickedStart] = useState("");
|
||||
@@ -180,40 +164,6 @@ export function TimeClockPanel({
|
||||
[clients, clientId],
|
||||
);
|
||||
|
||||
const featuredClientIds = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
const last = getLastTimeClockClientId();
|
||||
if (last) ids.push(last);
|
||||
|
||||
if (running?.clientId && !ids.includes(running.clientId)) {
|
||||
ids.unshift(running.clientId);
|
||||
}
|
||||
|
||||
for (const entry of todayEntries ?? []) {
|
||||
if (entry.clientId && !ids.includes(entry.clientId)) {
|
||||
ids.push(entry.clientId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const client of clients ?? []) {
|
||||
if (!ids.includes(client.id)) ids.push(client.id);
|
||||
if (ids.length >= FEATURED_CLIENT_COUNT) break;
|
||||
}
|
||||
|
||||
return ids;
|
||||
}, [clients, todayEntries, running]);
|
||||
|
||||
const visibleClients = useMemo(() => {
|
||||
if (!clients?.length) return [];
|
||||
if (showAllClients) return clients;
|
||||
const featured = featuredClientIds
|
||||
.map((id) => clients.find((c) => c.id === id))
|
||||
.filter((c): c is NonNullable<typeof c> => Boolean(c));
|
||||
return featured.length > 0 ? featured : clients.slice(0, FEATURED_CLIENT_COUNT);
|
||||
}, [clients, featuredClientIds, showAllClients]);
|
||||
|
||||
const hiddenClientCount = Math.max(0, (clients?.length ?? 0) - visibleClients.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (!running) return;
|
||||
@@ -373,292 +323,63 @@ export function TimeClockPanel({
|
||||
const runningTitle = formatRunningTimerLabel(running?.description);
|
||||
const activeClientId = running ? (running.clientId ?? "") : clientId;
|
||||
const activeInvoiceId = running ? (running.invoiceId ?? "") : invoiceId;
|
||||
const completedToday = todayEntries?.filter((entry) => entry.endedAt) ?? [];
|
||||
const todayHours = completedToday.reduce(
|
||||
(total, entry) => total + Number(entry.hours ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
||||
{running ? (
|
||||
<div className="border-primary/20 bg-primary/5 rounded-2xl border p-6 text-center shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-center gap-2">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
|
||||
</span>
|
||||
<span className="text-primary text-sm font-medium">Timer running</span>
|
||||
<div className={cn("flex flex-col gap-6", !compact && "xl:grid xl:grid-cols-[minmax(0,1fr)_22rem]")}>
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
{running ? "In progress" : "Ready to start"}
|
||||
</p>
|
||||
<CardTitle className="text-pretty text-2xl">
|
||||
{running ? runningTitle : "What are you working on?"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-pretty">
|
||||
{running
|
||||
? [
|
||||
running.client?.name ?? "No client",
|
||||
running.invoice ? invoiceLabel(running.invoice) : null,
|
||||
displayRate ? `$${displayRate}/hr` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "Add the details you know now. You can update the rest while the timer runs."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{running ? (
|
||||
<p
|
||||
aria-label={`${elapsed} elapsed seconds`}
|
||||
className="font-mono text-4xl font-semibold tracking-tight tabular-nums sm:text-5xl"
|
||||
>
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-primary font-mono text-5xl font-bold tracking-tight tabular-nums sm:text-6xl">
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-medium">{runningTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{running.client?.name ?? "No client"}
|
||||
{running.invoice ? ` · ${invoiceLabel(running.invoice)}` : ""}
|
||||
{displayRate ? ` · $${displayRate}/hr` : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{!running ? <Clock className="h-4 w-4" /> : null}
|
||||
{running ? "Update & stop" : "Clock in"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{!running ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clock-title" className="sr-only">
|
||||
What are you working on?
|
||||
</Label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="clock-title">Description</Label>
|
||||
<Input
|
||||
id="clock-title"
|
||||
name="description"
|
||||
autoComplete="off"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="What are you working on?"
|
||||
className="h-12 border-0 bg-transparent px-0 text-lg font-medium shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visibleClients.map((client) => (
|
||||
<ClientChip
|
||||
key={client.id}
|
||||
label={client.name}
|
||||
active={activeClientId === client.id}
|
||||
onClick={() => handleClientChange(client.id)}
|
||||
/>
|
||||
))}
|
||||
{!showAllClients && hiddenClientCount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={() => setShowAllClients(true)}
|
||||
>
|
||||
+{hiddenClientCount} more
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
|
||||
<Select value={clientId || undefined} onValueChange={handleClientChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<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-2">
|
||||
<Label>Invoice</Label>
|
||||
<Select
|
||||
value={invoiceId || "__none__"}
|
||||
onValueChange={handleInvoiceChange}
|
||||
disabled={!clientId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
clientId ? "Draft invoice (optional)" : "Choose a client first"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No invoice — save entry only</SelectItem>
|
||||
{billableInvoices?.map((inv) => (
|
||||
<SelectItem key={inv.id} value={inv.id}>
|
||||
{invoiceLabel(inv)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Collapsible open={optionsOpen} onOpenChange={setOptionsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground h-auto w-full justify-between px-0 py-1 font-normal hover:bg-transparent"
|
||||
>
|
||||
Rate & start time
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 transition-transform",
|
||||
optionsOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Hourly rate</Label>
|
||||
<NumberInput
|
||||
value={rate}
|
||||
onChange={setRate}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Client default: ${selectedClient.defaultHourlyRate}/hr (used when left at zero).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>When to start</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
["now", "Now"],
|
||||
["pick", "Pick time"],
|
||||
["ago", "Time ago"],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={startMode === mode ? "default" : "outline"}
|
||||
className="rounded-full"
|
||||
onClick={() => selectStartMode(mode)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{startMode === "pick" ? (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={pickedStart}
|
||||
onChange={(e) => setPickedStart(e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
) : null}
|
||||
{startMode === "ago" ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
value={minutesAgo}
|
||||
onChange={(e) => setMinutesAgo(e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">minutes ago</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RunningTextFields
|
||||
key={running.id}
|
||||
running={running}
|
||||
updateRunningPending={updateRunning.isPending}
|
||||
onDescriptionCommit={handleRunningDescriptionCommit}
|
||||
onStartedAtCommit={handleRunningStartedAtCommit}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visibleClients.map((client) => (
|
||||
<ClientChip
|
||||
key={client.id}
|
||||
label={client.name}
|
||||
active={activeClientId === client.id}
|
||||
onClick={() => handleClientChange(client.id)}
|
||||
/>
|
||||
))}
|
||||
{!showAllClients && hiddenClientCount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={() => setShowAllClients(true)}
|
||||
>
|
||||
+{hiddenClientCount} more
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
|
||||
<Select
|
||||
value={activeClientId || undefined}
|
||||
onValueChange={handleClientChange}
|
||||
disabled={updateRunning.isPending}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<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-2">
|
||||
<Label>Invoice</Label>
|
||||
<Select
|
||||
value={activeInvoiceId || "__none__"}
|
||||
onValueChange={handleInvoiceChange}
|
||||
disabled={!activeClientId || updateRunning.isPending}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
activeClientId
|
||||
? "Draft invoice (optional)"
|
||||
: "Choose a client first"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No invoice — save entry only</SelectItem>
|
||||
{billableInvoices?.map((inv) => (
|
||||
<SelectItem key={inv.id} value={inv.id}>
|
||||
{invoiceLabel(inv)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
|
||||
<Input
|
||||
id="clock-stop-note"
|
||||
value={stopNote}
|
||||
onChange={(e) => setStopNote(e.target.value)}
|
||||
placeholder={
|
||||
running?.description?.trim()
|
||||
? running.description
|
||||
: "Update description when you stop"
|
||||
}
|
||||
placeholder="e.g. Client kickoff…"
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{running ? (
|
||||
<Button
|
||||
@@ -672,49 +393,246 @@ export function TimeClockPanel({
|
||||
}
|
||||
disabled={clockOut.isPending}
|
||||
>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
{clockOut.isPending ? "Stopping…" : "Stop & save"}
|
||||
<Square data-icon="inline-start" aria-hidden="true" />
|
||||
{clockOut.isPending ? "Stopping…" : "Stop & save entry"}
|
||||
</Button>
|
||||
) : (
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label htmlFor="clock-client">Client</Label>
|
||||
<Select
|
||||
value={activeClientId || "__none__"}
|
||||
onValueChange={(value) => handleClientChange(value === "__none__" ? "" : value)}
|
||||
disabled={Boolean(running && updateRunning.isPending)}
|
||||
>
|
||||
<SelectTrigger id="clock-client" aria-label="Client" className="h-11 w-full">
|
||||
<SelectValue placeholder="Select client…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="__none__">No client</SelectItem>
|
||||
{clients?.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label htmlFor="clock-invoice">Invoice</Label>
|
||||
<Select
|
||||
value={activeInvoiceId || "__none__"}
|
||||
onValueChange={handleInvoiceChange}
|
||||
disabled={!activeClientId || Boolean(running && updateRunning.isPending)}
|
||||
>
|
||||
<SelectTrigger id="clock-invoice" aria-label="Invoice" className="h-11 w-full">
|
||||
<SelectValue
|
||||
placeholder={activeClientId ? "Select invoice…" : "Choose a client first"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="__none__">Entry only — no invoice</SelectItem>
|
||||
{billableInvoices?.map((invoice) => (
|
||||
<SelectItem key={invoice.id} value={invoice.id}>
|
||||
{invoiceLabel(invoice)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs text-pretty">
|
||||
{activeClientId
|
||||
? "Linking an invoice adds the completed time as a billable line item."
|
||||
: "Choose a client to see billable invoices."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible open={optionsOpen} onOpenChange={setOptionsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-between rounded-none border-t px-0 pt-4 pb-0 font-normal"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col items-start gap-0.5 text-left">
|
||||
<span className="font-medium">
|
||||
{running ? "Edit timer details" : "Rate & start time"}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{running
|
||||
? `Started ${new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(running.startedAt))}`
|
||||
: `${displayRate ? `$${displayRate}/hr` : "No rate"} · ${
|
||||
startMode === "now" ? "Starting now" : "Custom start"
|
||||
}`}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
data-icon="inline-end"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"shrink-0 transition-transform",
|
||||
optionsOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{running ? (
|
||||
<RunningTextFields
|
||||
key={running.id}
|
||||
running={running}
|
||||
updateRunningPending={updateRunning.isPending}
|
||||
onDescriptionCommit={handleRunningDescriptionCommit}
|
||||
onStartedAtCommit={handleRunningStartedAtCommit}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="clock-rate">Hourly rate</Label>
|
||||
<NumberInput
|
||||
id="clock-rate"
|
||||
value={rate}
|
||||
onChange={setRate}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{`Uses ${selectedClient.defaultHourlyRate}/hr from ${selectedClient.name}.`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>When to start</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
["now", "Now"],
|
||||
["pick", "Pick time"],
|
||||
["ago", "Time ago"],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={startMode === mode ? "default" : "outline"}
|
||||
className="rounded-full"
|
||||
aria-pressed={startMode === mode}
|
||||
onClick={() => selectStartMode(mode)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{startMode === "pick" ? (
|
||||
<Input
|
||||
aria-label="Start date and time"
|
||||
name="startedAt"
|
||||
autoComplete="off"
|
||||
type="datetime-local"
|
||||
value={pickedStart}
|
||||
onChange={(event) => setPickedStart(event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
{startMode === "ago" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
aria-label="Minutes ago"
|
||||
name="minutesAgo"
|
||||
autoComplete="off"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
value={minutesAgo}
|
||||
onChange={(event) => setMinutesAgo(event.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">minutes ago</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{running ? (
|
||||
<div className="mt-5 flex flex-col gap-2">
|
||||
<Label htmlFor="clock-stop-note">Note on stop</Label>
|
||||
<Input
|
||||
id="clock-stop-note"
|
||||
name="stopNote"
|
||||
autoComplete="off"
|
||||
value={stopNote}
|
||||
onChange={(event) => setStopNote(event.target.value)}
|
||||
placeholder="Add a final note…"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
|
||||
{!running ? (
|
||||
<CardFooter className="pt-0">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={handleStart}
|
||||
disabled={clockIn.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
<Play data-icon="inline-start" aria-hidden="true" />
|
||||
{clockIn.isPending ? "Starting…" : "Start timer"}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{!compact ? (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Today's entries</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="h-8" asChild>
|
||||
<Link href="/dashboard/time-clock/entries">View all entries</Link>
|
||||
</Button>
|
||||
<Card className="h-fit min-w-0">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<CardTitle>Today</CardTitle>
|
||||
<CardDescription>
|
||||
{completedToday.length === 1
|
||||
? "1 completed entry"
|
||||
: `${completedToday.length} completed entries`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<p className="font-mono text-xl font-semibold tabular-nums">
|
||||
{todayHours.toFixed(2)}h
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{todayEntries?.some((e) => e.endedAt) ? (
|
||||
<TimeEntryList
|
||||
entries={todayEntries}
|
||||
onEdit={(entry) => setEditEntryId(entry.id)}
|
||||
/>
|
||||
{completedToday.length > 0 ? (
|
||||
<TimeEntryList entries={completedToday} onEdit={(entry) => setEditEntryId(entry.id)} />
|
||||
) : (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
No entries today.{" "}
|
||||
<Link
|
||||
href="/dashboard/time-clock/entries"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
View history
|
||||
</Link>
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 py-6 text-center">
|
||||
<p className="font-medium">No time logged yet</p>
|
||||
<p className="text-muted-foreground text-sm text-pretty">
|
||||
Start your first timer or open history to add an entry manually.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href="/dashboard/time-clock/entries">View time history</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/** Default invoice number format (matches web/mobile create forms). */
|
||||
export function generateInvoiceNumber(now = new Date()): string {
|
||||
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const date = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, "0"),
|
||||
String(now.getDate()).padStart(2, "0"),
|
||||
].join("");
|
||||
|
||||
return `INV-${date}-${String(now.getTime()).slice(-6)}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user