Initial commit — eLegal Software monorepo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-04-26 02:42:42 -04:00
co-authored by Claude Sonnet 4.6
commit 0700d54225
160 changed files with 22771 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useMe } from '@/hooks/useAuth';
import { Sidebar } from './Sidebar';
import { Topbar } from './Topbar';
export function AppLayout() {
const me = useMe();
if (me.isLoading) {
return <div className="min-h-screen grid place-items-center text-sm text-ink-500">Loading</div>;
}
if (!me.data) {
return <Navigate to="/login" replace />;
}
return (
<div className="min-h-screen flex bg-ink-50">
<Sidebar />
<div className="flex-1 flex flex-col min-w-0">
<Topbar />
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
</div>
);
}
export function PageHeader({
title,
description,
action,
}: {
title: string;
description?: React.ReactNode;
action?: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-ink-950 font-display">{title}</h1>
{description && <p className="mt-1 text-sm text-ink-600">{description}</p>}
</div>
{action && <div className="flex items-center gap-2">{action}</div>}
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
import { useState } from 'react';
import { CreditCard, Sparkles, Crown } from 'lucide-react';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useBillingStatus, useStartCheckout, useOpenPortal } from '@/hooks/useBilling';
const PLAN_TONES: Record<'starter' | 'pro' | 'lifetime', 'neutral' | 'brand' | 'emerald'> = {
starter: 'neutral',
pro: 'brand',
lifetime: 'emerald',
};
const PLAN_LABEL: Record<'starter' | 'pro' | 'lifetime', string> = {
starter: 'Starter',
pro: 'Professional',
lifetime: 'Lifetime',
};
export function BillingCard() {
const status = useBillingStatus();
const checkout = useStartCheckout();
const portal = useOpenPortal();
const [error, setError] = useState<string | null>(null);
async function startCheckout(plan: 'pro' | 'lifetime') {
setError(null);
try {
const { url } = await checkout.mutateAsync({ plan });
if (url) window.location.href = url;
} catch (e) {
const code = (e as { code?: string }).code;
setError(
code === 'stripe_not_configured'
? 'Billing is not configured yet. Contact support.'
: code === 'plan_not_configured'
? 'This plan is not available yet.'
: 'Could not start checkout.',
);
}
}
async function openPortal() {
setError(null);
try {
const { url } = await portal.mutateAsync();
if (url) window.location.href = url;
} catch {
setError('Could not open billing portal.');
}
}
if (status.isLoading) {
return (
<Card>
<CardHeader title="Plan & billing" />
<CardBody>
<p className="text-sm text-ink-500">Loading</p>
</CardBody>
</Card>
);
}
const plan = status.data?.plan ?? 'starter';
const isPaid = plan !== 'starter';
const configured = !!status.data?.configured;
return (
<Card>
<CardHeader
title="Plan & billing"
description={isPaid ? 'Manage your subscription and payment details.' : 'Upgrade to remove limits and watermarks.'}
/>
<CardBody className="space-y-5">
<div className="flex items-center justify-between rounded-xl border border-ink-100 bg-ink-50/50 p-4">
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-white text-brand-600 shadow-sm">
<CreditCard className="h-4 w-4" />
</div>
<div>
<p className="text-xs uppercase tracking-wider text-ink-500">Current plan</p>
<p className="font-semibold text-ink-900">
{PLAN_LABEL[plan]} <Badge tone={PLAN_TONES[plan]}>{plan}</Badge>
</p>
</div>
</div>
{status.data?.hasCustomer && (
<Button variant="secondary" size="sm" onClick={openPortal} disabled={portal.isPending}>
{portal.isPending ? 'Opening…' : 'Manage billing'}
</Button>
)}
</div>
{!configured && (
<p className="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2 text-sm text-amber-800">
Stripe isn't configured on this server yet. Set <code>STRIPE_SECRET_KEY</code> and the price IDs in your environment to enable checkout.
</p>
)}
{plan === 'starter' && (
<div className="grid gap-3 sm:grid-cols-2">
<PlanOption
icon={<Sparkles className="h-4 w-4" />}
name="Professional"
price="$25/mo"
points={['Unlimited clients & invoices', '6 active cases', '8GB storage', 'No watermark']}
cta="Upgrade to Pro"
onClick={() => startCheckout('pro')}
loading={checkout.isPending}
disabled={!configured}
/>
<PlanOption
icon={<Crown className="h-4 w-4" />}
name="Lifetime"
price="$129 once"
points={['Everything in Pro', 'Unlimited cases', '50GB storage', 'Future updates']}
cta="Get Lifetime"
onClick={() => startCheckout('lifetime')}
loading={checkout.isPending}
disabled={!configured}
highlight
/>
</div>
)}
{plan === 'pro' && (
<p className="text-sm text-ink-600">
You're on the Professional plan ($25/mo). Want a lifetime license instead?{' '}
<button
type="button"
className="font-semibold text-brand-600 hover:text-brand-700"
onClick={() => startCheckout('lifetime')}
disabled={checkout.isPending || !configured}
>
Upgrade to Lifetime
</button>
.
</p>
)}
{plan === 'lifetime' && (
<p className="text-sm text-ink-600">
You're on the Lifetime plan. No renewal needed — you have full access forever.
</p>
)}
{error && <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p>}
</CardBody>
</Card>
);
}
function PlanOption({
icon,
name,
price,
points,
cta,
onClick,
loading,
disabled,
highlight,
}: {
icon: React.ReactNode;
name: string;
price: string;
points: string[];
cta: string;
onClick: () => void;
loading: boolean;
disabled: boolean;
highlight?: boolean;
}) {
return (
<div
className={
'rounded-xl border p-4 ' +
(highlight ? 'border-brand-300 bg-brand-50/30' : 'border-ink-100')
}
>
<div className="flex items-center gap-2 text-ink-900">
<span className="grid h-7 w-7 place-items-center rounded-md bg-white text-brand-600 shadow-sm">
{icon}
</span>
<p className="font-semibold">{name}</p>
<span className="ml-auto text-sm font-bold text-ink-950">{price}</span>
</div>
<ul className="mt-3 space-y-1 text-xs text-ink-600">
{points.map((p) => (
<li key={p}>· {p}</li>
))}
</ul>
<Button
size="sm"
variant={highlight ? 'primary' : 'secondary'}
className="mt-4 w-full"
onClick={onClick}
disabled={loading || disabled}
>
{loading ? 'Redirecting' : cta}
</Button>
</div>
);
}
@@ -0,0 +1,84 @@
import { useState } from 'react';
import { Plus, Trash2, Clock } from 'lucide-react';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { ManualEntryDrawer } from './ManualEntryDrawer';
import { useTimeEntries, useDeleteTimeEntry, type TimeEntry } from '@/hooks/useTime';
import { formatDate, formatHours, formatMoney } from '@/lib/format';
function entryAmount(e: TimeEntry): number {
if (!e.billable) return 0;
return (Number(e.rate) || 0) * (e.minutes / 60);
}
export function CaseTimeList({ caseId }: { caseId: string }) {
const list = useTimeEntries({ caseId });
const del = useDeleteTimeEntry();
const [drawerOpen, setDrawerOpen] = useState(false);
const totalMinutes = (list.data?.items ?? []).reduce((acc, e) => acc + e.minutes, 0);
const totalAmount = (list.data?.items ?? []).reduce((acc, e) => acc + entryAmount(e), 0);
return (
<Card>
<CardHeader
title="Time entries"
description={
totalMinutes > 0 ? `${formatHours(totalMinutes)} · ${formatMoney(totalAmount)} billable` : 'No time logged yet.'
}
action={
<Button size="sm" onClick={() => setDrawerOpen(true)}>
<Plus className="h-3.5 w-3.5" />
Log time
</Button>
}
/>
{list.isLoading ? (
<div className="px-6 py-10 text-center text-sm text-ink-500">Loading</div>
) : !list.data?.items.length ? (
<EmptyState
icon={<Clock className="h-5 w-5" />}
title="No time logged"
description="Start the timer in the topbar or log time manually."
/>
) : (
<ul className="divide-y divide-ink-100">
{list.data.items.map((e) => (
<li key={e.id} className="flex items-center gap-4 px-5 py-3 hover:bg-ink-50/50">
<div className="min-w-0 flex-1">
<p className="text-sm text-ink-900 truncate">{e.description}</p>
<p className="text-xs text-ink-500 mt-0.5">{formatDate(e.startedAt)}</p>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-ink-900 tabular-nums">{formatHours(e.minutes)}</p>
<p className="text-xs text-ink-500">
{e.billable ? formatMoney(entryAmount(e)) : 'Non-billable'}
</p>
</div>
{e.invoiceItemId ? (
<Badge tone="brand">Invoiced</Badge>
) : !e.endedAt ? (
<Badge tone="emerald">Running</Badge>
) : (
<button
type="button"
onClick={() => {
if (confirm('Delete this time entry?')) del.mutate(e.id);
}}
className="grid h-8 w-8 place-items-center rounded-lg text-ink-400 hover:text-rose-600 hover:bg-rose-50 transition"
aria-label="Delete entry"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</li>
))}
</ul>
)}
<ManualEntryDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} initialCaseId={caseId} />
</Card>
);
}
@@ -0,0 +1,360 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, FileText } from 'lucide-react';
import { Drawer } from '@/components/ui/Drawer';
import { Button } from '@/components/ui/Button';
import { Input, Select, Textarea } from '@/components/ui/Input';
import { useClients } from '@/hooks/useClients';
import { useCases } from '@/hooks/useCases';
import { useTimeEntries } from '@/hooks/useTime';
import { useCreateInvoice, type CreateInvoiceInput } from '@/hooks/useInvoices';
import { formatDate, formatHours, formatMoney, planLimitMessage } from '@/lib/format';
import { cn } from '@/lib/cn';
interface ManualItem {
description: string;
quantity: string;
rate: string;
}
type Mode = 'manual' | 'time';
interface Props {
open: boolean;
onClose: () => void;
initialClientId?: string;
initialCaseId?: string;
onCreated?: (invoiceId: string) => void;
}
function defaultDueDate(): string {
const d = new Date();
d.setDate(d.getDate() + 30);
return d.toISOString().slice(0, 10);
}
export function CreateInvoiceDrawer({ open, onClose, initialClientId, initialCaseId, onCreated }: Props) {
const clients = useClients();
const cases = useCases();
const create = useCreateInvoice();
const [mode, setMode] = useState<Mode>(initialCaseId ? 'time' : 'manual');
const [clientId, setClientId] = useState(initialClientId ?? '');
const [caseId, setCaseId] = useState(initialCaseId ?? '');
const [taxRate, setTaxRate] = useState('0');
const [dueDate, setDueDate] = useState(defaultDueDate());
const [notes, setNotes] = useState('');
const [items, setItems] = useState<ManualItem[]>([{ description: '', quantity: '1', rate: '' }]);
const [selectedTimeIds, setSelectedTimeIds] = useState<Set<string>>(new Set());
// Reset on open
useEffect(() => {
if (!open) return;
setMode(initialCaseId ? 'time' : 'manual');
setClientId(initialClientId ?? '');
setCaseId(initialCaseId ?? '');
setTaxRate('0');
setDueDate(defaultDueDate());
setNotes('');
setItems([{ description: '', quantity: '1', rate: '' }]);
setSelectedTimeIds(new Set());
create.reset();
}, [open, initialClientId, initialCaseId, create]);
// When client changes, clear case selection if the case doesn't belong to that client
useEffect(() => {
if (!caseId) return;
const c = cases.data?.items.find((x) => x.id === caseId);
if (c && c.clientId !== clientId) setCaseId('');
}, [clientId, caseId, cases.data]);
// Pull unbilled time entries for the chosen case (or for any case of the client if no case)
const unbilledTime = useTimeEntries(
mode === 'time'
? caseId
? { caseId, invoiced: 'false' }
: { invoiced: 'false' }
: { invoiced: 'false' },
);
const filteredEntries = useMemo(() => {
const all = unbilledTime.data?.items ?? [];
return all.filter((e) => {
if (!e.billable) return false;
if (e.endedAt === null) return false; // skip running timer
if (clientId && e.clientId !== clientId) return false;
if (caseId && e.caseId !== caseId) return false;
return true;
});
}, [unbilledTime.data, clientId, caseId]);
function toggleTime(id: string) {
setSelectedTimeIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
// Live totals preview
const previewSubtotal = useMemo(() => {
if (mode === 'manual') {
return items.reduce((acc, it) => acc + (Number(it.quantity) || 0) * (Number(it.rate) || 0), 0);
}
return filteredEntries
.filter((e) => selectedTimeIds.has(e.id))
.reduce((acc, e) => acc + (Number(e.rate) || 0) * (e.minutes / 60), 0);
}, [mode, items, filteredEntries, selectedTimeIds]);
const previewTotal = previewSubtotal * (1 + (Number(taxRate) || 0) / 100);
const clientCases = useMemo(
() => (cases.data?.items ?? []).filter((c) => !clientId || c.clientId === clientId),
[cases.data, clientId],
);
async function onSubmit() {
if (!clientId) return;
const payload: CreateInvoiceInput = {
clientId,
caseId: caseId || null,
notes: notes.trim() || null,
taxRate: Number(taxRate) || 0,
dueAt: dueDate ? new Date(`${dueDate}T00:00:00`).toISOString() : null,
};
if (mode === 'manual') {
payload.items = items
.filter((it) => it.description.trim() && Number(it.quantity) > 0 && Number(it.rate) >= 0)
.map((it) => ({
description: it.description.trim(),
quantity: Number(it.quantity),
rate: Number(it.rate),
}));
} else {
payload.timeEntryIds = Array.from(selectedTimeIds);
}
if (!payload.items?.length && !payload.timeEntryIds?.length) return;
const created = await create.mutateAsync(payload);
onCreated?.(created.id);
onClose();
}
const apiErr = create.error ? planLimitMessage(create.error.code, 'Could not create the invoice.') : null;
const canSubmit =
!!clientId &&
((mode === 'manual' &&
items.some((it) => it.description.trim() && Number(it.quantity) > 0 && Number(it.rate) >= 0)) ||
(mode === 'time' && selectedTimeIds.size > 0));
return (
<Drawer
open={open}
onClose={onClose}
title="New invoice"
description="Create a draft from time entries or build it manually."
width="max-w-2xl"
footer={
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-ink-600">
<span className="text-xs text-ink-500">Total</span>{' '}
<span className="font-semibold text-ink-900">{formatMoney(previewTotal)}</span>
</p>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={!canSubmit || create.isPending}>
{create.isPending ? 'Creating…' : 'Create draft'}
</Button>
</div>
</div>
}
>
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<Select label="Client" value={clientId} onChange={(e) => setClientId(e.target.value)}>
<option value="">Select a client</option>
{clients.data?.items.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
<Select label="Case (optional)" value={caseId} onChange={(e) => setCaseId(e.target.value)} disabled={!clientId}>
<option value="">No case</option>
{clientCases.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</Select>
</div>
<div className="flex rounded-xl border border-ink-200 p-1 bg-ink-50/50 w-fit">
<ModeButton active={mode === 'time'} onClick={() => setMode('time')}>From time entries</ModeButton>
<ModeButton active={mode === 'manual'} onClick={() => setMode('manual')}>Manual</ModeButton>
</div>
{mode === 'time' ? (
<div className="rounded-xl border border-ink-100">
<div className="border-b border-ink-100 px-4 py-3 flex items-center justify-between">
<p className="text-sm font-medium text-ink-700">Unbilled time entries</p>
<p className="text-xs text-ink-500">{selectedTimeIds.size} selected</p>
</div>
{!clientId ? (
<div className="px-6 py-10 text-center text-sm text-ink-500">Select a client first.</div>
) : !filteredEntries.length ? (
<div className="px-6 py-10 text-center text-sm text-ink-500">
No unbilled, billable time entries{caseId ? ' for this case' : ' for this client'}.
</div>
) : (
<ul className="max-h-72 overflow-y-auto divide-y divide-ink-100">
{filteredEntries.map((e) => {
const checked = selectedTimeIds.has(e.id);
const amount = (Number(e.rate) || 0) * (e.minutes / 60);
return (
<li key={e.id}>
<label
className={cn(
'flex items-center gap-3 px-4 py-2.5 cursor-pointer hover:bg-ink-50/50 transition',
checked && 'bg-brand-50/50',
)}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleTime(e.id)}
className="h-4 w-4 rounded border-ink-300"
/>
<div className="min-w-0 flex-1">
<p className="text-sm text-ink-900 truncate">{e.description}</p>
<p className="text-xs text-ink-500 truncate">
{e.caseTitle} · {formatDate(e.startedAt)}
</p>
</div>
<p className="text-xs text-ink-500 tabular-nums">{formatHours(e.minutes)}</p>
<p className="text-sm font-semibold text-ink-900 tabular-nums w-20 text-right">
{formatMoney(amount)}
</p>
</label>
</li>
);
})}
</ul>
)}
</div>
) : (
<div className="rounded-xl border border-ink-100">
<div className="border-b border-ink-100 px-4 py-3 flex items-center justify-between">
<p className="text-sm font-medium text-ink-700">Line items</p>
<Button
size="sm"
variant="secondary"
onClick={() => setItems((s) => [...s, { description: '', quantity: '1', rate: '' }])}
>
<Plus className="h-3.5 w-3.5" />
Add line
</Button>
</div>
<ul className="divide-y divide-ink-100">
{items.map((it, i) => (
<li key={i} className="grid grid-cols-12 gap-2 px-4 py-3 items-start">
<input
placeholder="Description"
value={it.description}
onChange={(e) => setItems((s) => s.map((x, j) => (j === i ? { ...x, description: e.target.value } : x)))}
className="col-span-6 rounded-lg border border-ink-200 px-3 py-2 text-sm focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
/>
<input
type="number"
min="0"
step="0.01"
placeholder="Qty"
value={it.quantity}
onChange={(e) => setItems((s) => s.map((x, j) => (j === i ? { ...x, quantity: e.target.value } : x)))}
className="col-span-2 rounded-lg border border-ink-200 px-3 py-2 text-sm text-right focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
/>
<input
type="number"
min="0"
step="0.01"
placeholder="Rate"
value={it.rate}
onChange={(e) => setItems((s) => s.map((x, j) => (j === i ? { ...x, rate: e.target.value } : x)))}
className="col-span-3 rounded-lg border border-ink-200 px-3 py-2 text-sm text-right focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
/>
<button
type="button"
onClick={() => setItems((s) => (s.length > 1 ? s.filter((_, j) => j !== i) : s))}
className="col-span-1 grid place-items-center text-ink-400 hover:text-rose-600 h-9"
aria-label="Remove line"
>
<Trash2 className="h-4 w-4" />
</button>
</li>
))}
</ul>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Tax rate (%)"
type="number"
min="0"
max="100"
step="0.01"
value={taxRate}
onChange={(e) => setTaxRate(e.target.value)}
/>
<Input
label="Due date"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<Textarea
label="Notes"
rows={3}
placeholder="Payment terms, thank-you note, etc."
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
{apiErr && <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiErr}</p>}
<p className="flex items-start gap-2 text-xs text-ink-500">
<FileText className="h-3.5 w-3.5 mt-0.5 flex-none" />
The invoice will be created as a draft. You can review and send it from the invoice page.
</p>
</div>
</Drawer>
);
}
function ModeButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'rounded-lg px-3 py-1.5 text-xs font-semibold transition',
active ? 'bg-white text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-800',
)}
>
{children}
</button>
);
}
@@ -0,0 +1,155 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Drawer } from '@/components/ui/Drawer';
import { Button } from '@/components/ui/Button';
import { Input, Select, Textarea } from '@/components/ui/Input';
import { useCases } from '@/hooks/useCases';
import { useCreateTimeEntry } from '@/hooks/useTime';
interface FormValues {
caseId: string;
date: string;
minutes: string;
description: string;
rate?: string;
billable: boolean;
}
interface Props {
open: boolean;
onClose: () => void;
initialCaseId?: string;
}
function todayLocal(): string {
const d = new Date();
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 10);
}
export function ManualEntryDrawer({ open, onClose, initialCaseId }: Props) {
const cases = useCases();
const create = useCreateTimeEntry();
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormValues>({
defaultValues: {
caseId: initialCaseId ?? '',
date: todayLocal(),
minutes: '60',
description: '',
rate: '',
billable: true,
},
});
useEffect(() => {
if (open) {
reset({
caseId: initialCaseId ?? '',
date: todayLocal(),
minutes: '60',
description: '',
rate: '',
billable: true,
});
create.reset();
}
}, [open, initialCaseId, reset, create]);
async function onSubmit(values: FormValues) {
const minutes = Number(values.minutes);
if (!Number.isFinite(minutes) || minutes <= 0) return;
const startedAt = new Date(`${values.date}T09:00:00`);
const endedAt = new Date(startedAt.getTime() + minutes * 60_000);
await create.mutateAsync({
caseId: values.caseId,
description: values.description.trim(),
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
minutes,
rate: values.rate ? Number(values.rate) : undefined,
billable: values.billable,
});
onClose();
}
return (
<Drawer
open={open}
onClose={onClose}
title="Log time"
description="Add a manual time entry."
footer={
<div className="flex items-center justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit(onSubmit)} disabled={create.isPending}>
{create.isPending ? 'Saving…' : 'Save entry'}
</Button>
</div>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Select
label="Case"
error={errors.caseId?.message}
{...register('caseId', { required: 'Pick a case' })}
>
<option value="">Select a case</option>
{cases.data?.items.map((c) => (
<option key={c.id} value={c.id}>
{c.title} · {c.clientName}
</option>
))}
</Select>
<div className="grid gap-4 md:grid-cols-2">
<Input label="Date" type="date" {...register('date', { required: true })} />
<Input
label="Minutes"
type="number"
min={1}
step={15}
{...register('minutes', { required: true })}
hint="Enter the duration in minutes."
/>
</div>
<Textarea
label="Description"
rows={3}
placeholder="Drafting reply brief, research, client meeting…"
{...register('description', { required: 'Required', maxLength: 500 })}
error={errors.description?.message}
/>
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Rate (USD)"
type="number"
min={0}
step="0.01"
placeholder="Defaults to case rate"
{...register('rate')}
/>
<label className="flex items-center gap-2 mt-6 text-sm text-ink-700">
<input type="checkbox" className="h-4 w-4 rounded border-ink-300" {...register('billable')} />
Billable
</label>
</div>
{create.error && (
<p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">
Could not save the entry. {create.error.code ? `(${create.error.code})` : ''}
</p>
)}
</form>
</Drawer>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard,
Users,
Briefcase,
Clock,
FileText,
Receipt,
Settings,
} from 'lucide-react';
import type { ComponentType } from 'react';
import { Logo } from '@/components/marketing/Logo';
import { cn } from '@/lib/cn';
interface Item {
to: string;
label: string;
icon: ComponentType<{ className?: string }>;
badge?: string;
}
const NAV: Item[] = [
{ to: '/app', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/app/clients', label: 'Clients', icon: Users },
{ to: '/app/cases', label: 'Cases', icon: Briefcase },
{ to: '/app/time', label: 'Time', icon: Clock },
{ to: '/app/documents', label: 'Documents', icon: FileText, badge: 'Soon' },
{ to: '/app/invoices', label: 'Invoices', icon: Receipt },
];
const NAV_FOOT: Item[] = [{ to: '/app/settings', label: 'Settings', icon: Settings }];
export function Sidebar() {
return (
<aside className="hidden md:flex md:w-60 lg:w-64 flex-col border-r border-ink-100 bg-white">
<div className="px-5 py-5">
<Logo />
</div>
<nav className="flex-1 px-3 space-y-0.5">
{NAV.map((item) => (
<NavItem key={item.to} item={item} />
))}
</nav>
<div className="p-3 border-t border-ink-100">
{NAV_FOOT.map((item) => (
<NavItem key={item.to} item={item} />
))}
</div>
</aside>
);
}
function NavItem({ item }: { item: Item }) {
return (
<NavLink
to={item.to}
end={item.to === '/app'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition',
isActive ? 'bg-brand-50 text-brand-700 font-medium' : 'text-ink-600 hover:bg-ink-50 hover:text-ink-900',
)
}
>
<item.icon className="h-4 w-4" />
<span className="flex-1">{item.label}</span>
{item.badge && (
<span className="rounded-full bg-ink-100 text-ink-500 text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5">
{item.badge}
</span>
)}
</NavLink>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useRef, useState } from 'react';
import { Play, Square, Timer } from 'lucide-react';
import { useActiveTimer, useStartTimer, useStopTimer } from '@/hooks/useTime';
import { useCases } from '@/hooks/useCases';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
function formatElapsed(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
export function TimerWidget() {
const active = useActiveTimer();
const stop = useStopTimer();
const [pickerOpen, setPickerOpen] = useState(false);
const startedAt = active.data?.active?.startedAt;
const [, setTick] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!startedAt) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
intervalRef.current = setInterval(() => setTick((t) => t + 1), 1000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [startedAt]);
if (active.isLoading) return null;
const running = active.data?.active;
if (!running) {
return (
<>
<Button variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
<Play className="h-3.5 w-3.5" />
Start timer
</Button>
{pickerOpen && <StartPicker onClose={() => setPickerOpen(false)} />}
</>
);
}
const elapsedSec = Math.max(0, Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000));
return (
<div className="flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 pl-2 pr-1 py-1">
<span className="flex items-center gap-1.5 text-xs">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
<span className="hidden md:inline text-emerald-800 font-medium max-w-[180px] truncate">
{running.caseTitle}
</span>
</span>
<span className="font-mono text-sm tabular-nums text-emerald-900 px-1.5">
{formatElapsed(elapsedSec)}
</span>
<button
type="button"
onClick={() => running && stop.mutate(running.id)}
disabled={stop.isPending}
className={cn(
'grid h-7 w-7 place-items-center rounded-full bg-white text-emerald-700 hover:bg-emerald-100 transition',
stop.isPending && 'opacity-50',
)}
aria-label="Stop timer"
>
<Square className="h-3.5 w-3.5 fill-current" />
</button>
</div>
);
}
function StartPicker({ onClose }: { onClose: () => void }) {
const cases = useCases({ status: 'open' });
const start = useStartTimer();
const [caseId, setCaseId] = useState('');
const [description, setDescription] = useState('');
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
async function onStart() {
if (!caseId) return;
await start.mutateAsync({ caseId, description: description.trim() || undefined });
onClose();
}
return (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-ink-950/40" onClick={onClose} />
<div className="absolute right-6 top-20 w-[360px] rounded-2xl border border-ink-100 bg-white p-5 shadow-2xl">
<div className="flex items-center gap-2 text-ink-900">
<div className="grid h-8 w-8 place-items-center rounded-lg bg-brand-50 text-brand-600">
<Timer className="h-4 w-4" />
</div>
<h3 className="text-sm font-semibold">Start a timer</h3>
</div>
<div className="mt-4 space-y-3">
<div>
<label className="text-xs font-medium text-ink-700 block mb-1.5">Case</label>
<select
value={caseId}
onChange={(e) => setCaseId(e.target.value)}
className="w-full rounded-xl border border-ink-200 px-3 py-2 text-sm focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
>
<option value="">Select a case</option>
{cases.data?.items.map((c) => (
<option key={c.id} value={c.id}>
{c.title} · {c.clientName}
</option>
))}
</select>
{!cases.data?.items.length && (
<p className="mt-1.5 text-xs text-ink-500">Open a case first to start tracking time.</p>
)}
</div>
<div>
<label className="text-xs font-medium text-ink-700 block mb-1.5">What are you working on?</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Drafting reply brief"
className="w-full rounded-xl border border-ink-200 px-3 py-2 text-sm placeholder:text-ink-400 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
/>
</div>
{start.error && (
<p className="rounded-lg bg-rose-50 px-3 py-2 text-xs text-rose-700">
{start.error.code === 'timer_already_running'
? 'You already have a running timer.'
: 'Could not start the timer.'}
</p>
)}
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" onClick={onStart} disabled={!caseId || start.isPending}>
<Play className="h-3.5 w-3.5" />
Start
</Button>
</div>
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { LogOut, Search, ChevronDown, ShieldAlert } from 'lucide-react';
import { useLogout, useMe } from '@/hooks/useAuth';
import { TimerWidget } from './TimerWidget';
export function Topbar() {
const me = useMe();
const logout = useLogout();
const [open, setOpen] = useState(false);
const initials = (me.data?.fullName ?? me.data?.email ?? '?')
.split(' ')
.map((n) => n[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<header className="flex h-16 items-center justify-between gap-4 border-b border-ink-100 bg-white px-6">
<div className="relative max-w-sm flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" />
<input
type="search"
placeholder="Search…"
className="w-full rounded-xl border border-ink-200 bg-ink-50/40 pl-9 pr-3 py-2 text-sm placeholder:text-ink-400 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
/>
</div>
<div className="flex items-center gap-3">
<TimerWidget />
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 rounded-full px-2 py-1.5 hover:bg-ink-100"
>
<span className="grid h-8 w-8 place-items-center rounded-full bg-brand-100 text-brand-700 text-xs font-semibold">
{initials}
</span>
<span className="hidden md:inline text-sm font-medium text-ink-800">
{me.data?.fullName ?? me.data?.email}
</span>
<ChevronDown className="h-4 w-4 text-ink-500" />
</button>
{open && (
<div
className="absolute right-0 top-full z-30 mt-2 w-56 rounded-xl border border-ink-100 bg-white shadow-lg"
onMouseLeave={() => setOpen(false)}
>
<div className="border-b border-ink-100 px-4 py-3">
<p className="text-sm font-medium text-ink-900">{me.data?.fullName ?? '—'}</p>
<p className="text-xs text-ink-500">{me.data?.email}</p>
</div>
{me.data?.isSuperadmin && (
<Link
to="/admin"
onClick={() => setOpen(false)}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-rose-700 hover:bg-rose-50 border-b border-ink-100"
>
<ShieldAlert className="h-4 w-4" />
Superadmin
</Link>
)}
<button
type="button"
onClick={() => logout.mutate()}
className="flex w-full items-center gap-2 px-4 py-2.5 text-sm text-ink-700 hover:bg-ink-50"
>
<LogOut className="h-4 w-4" />
Log out
</button>
</div>
)}
</div>
</div>
</header>
);
}