Initial commit — eLegal Software monorepo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Clock, Play, Square, Plus, Download, Trash2 } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||
import { Card, CardBody, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useTrackTool } from '@/hooks/useToolUsage';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
|
||||
interface Entry {
|
||||
id: string;
|
||||
description: string;
|
||||
matter: string;
|
||||
minutes: number;
|
||||
rate: number;
|
||||
date: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'lawdesk:tracker-entries';
|
||||
|
||||
function loadEntries(): Entry[] {
|
||||
if (typeof localStorage === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as Entry[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveEntries(entries: Entry[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
}
|
||||
|
||||
function fmtElapsed(s: number) {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
return h > 0
|
||||
? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
: `${m}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function todayLocal() {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function escapeCsv(v: string) {
|
||||
if (v.includes('"') || v.includes(',') || v.includes('\n')) {
|
||||
return `"${v.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export default function BillableHoursTrackerPage() {
|
||||
useTrackTool('billable-hours-tracker');
|
||||
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
useEffect(() => setEntries(loadEntries()), []);
|
||||
|
||||
// Live timer state
|
||||
const [running, setRunning] = useState<{ startedAt: number; description: string; matter: string; rate: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [, force] = useState(0);
|
||||
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (running) {
|
||||
tickRef.current = setInterval(() => force((n) => n + 1), 1000);
|
||||
return () => {
|
||||
if (tickRef.current) clearInterval(tickRef.current);
|
||||
};
|
||||
}
|
||||
}, [running]);
|
||||
|
||||
// Form state for the timer + manual entry
|
||||
const [description, setDescription] = useState('');
|
||||
const [matter, setMatter] = useState('');
|
||||
const [rate, setRate] = useState('250');
|
||||
const [manualMinutes, setManualMinutes] = useState('60');
|
||||
|
||||
function startTimer() {
|
||||
if (!description.trim()) return;
|
||||
setRunning({ startedAt: Date.now(), description: description.trim(), matter: matter.trim(), rate: Number(rate) || 0 });
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (!running) return;
|
||||
const minutes = Math.max(1, Math.round((Date.now() - running.startedAt) / 60000));
|
||||
addEntry({
|
||||
id: crypto.randomUUID(),
|
||||
description: running.description,
|
||||
matter: running.matter,
|
||||
minutes,
|
||||
rate: running.rate,
|
||||
date: todayLocal(),
|
||||
});
|
||||
setRunning(null);
|
||||
setDescription('');
|
||||
}
|
||||
|
||||
function addManual() {
|
||||
const m = Number(manualMinutes);
|
||||
if (!description.trim() || !Number.isFinite(m) || m <= 0) return;
|
||||
addEntry({
|
||||
id: crypto.randomUUID(),
|
||||
description: description.trim(),
|
||||
matter: matter.trim(),
|
||||
minutes: m,
|
||||
rate: Number(rate) || 0,
|
||||
date: todayLocal(),
|
||||
});
|
||||
setDescription('');
|
||||
}
|
||||
|
||||
function addEntry(e: Entry) {
|
||||
const next = [e, ...entries];
|
||||
setEntries(next);
|
||||
saveEntries(next);
|
||||
}
|
||||
|
||||
function removeEntry(id: string) {
|
||||
const next = entries.filter((e) => e.id !== id);
|
||||
setEntries(next);
|
||||
saveEntries(next);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (!confirm('Delete all entries from this browser?')) return;
|
||||
setEntries([]);
|
||||
saveEntries([]);
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const header = ['Date', 'Matter', 'Description', 'Minutes', 'Rate', 'Amount'];
|
||||
const lines = [header.join(',')];
|
||||
for (const e of entries) {
|
||||
const amount = (e.minutes / 60) * e.rate;
|
||||
lines.push(
|
||||
[e.date, escapeCsv(e.matter), escapeCsv(e.description), e.minutes, e.rate, amount.toFixed(2)].join(','),
|
||||
);
|
||||
}
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `billable-hours-${todayLocal()}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const totalMinutes = entries.reduce((s, e) => s + e.minutes, 0);
|
||||
const totalAmount = entries.reduce((s, e) => s + (e.minutes / 60) * e.rate, 0);
|
||||
const elapsed = running ? Math.floor((Date.now() - running.startedAt) / 1000) : 0;
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="container py-12 max-w-5xl">
|
||||
<Link to="/tools" className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-6">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
All tools
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-xl bg-brand-50 text-brand-600">
|
||||
<Clock className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-ink-950 font-display">Billable Hours Tracker</h1>
|
||||
<p className="text-sm text-ink-600">Saved to your browser, never sent to our server. Export to CSV anytime.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-5">
|
||||
<Card className="md:col-span-3">
|
||||
<CardHeader title="New entry" description="Start a timer or log time manually." />
|
||||
<CardBody className="space-y-4">
|
||||
<Input label="Matter / case" value={matter} onChange={(e) => setMatter(e.target.value)} placeholder="Smith v. Johnson" />
|
||||
<Input label="What are you working on?" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Drafting reply brief" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Hourly rate (USD)" type="number" min={0} step="1" value={rate} onChange={(e) => setRate(e.target.value)} />
|
||||
<Input label="Manual minutes" type="number" min={1} step="15" value={manualMinutes} onChange={(e) => setManualMinutes(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{!running ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={startTimer} disabled={!description.trim()}>
|
||||
<Play className="h-4 w-4" />
|
||||
Start timer
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={addManual} disabled={!description.trim()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Log manual entry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50/60 p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-emerald-700">Running</p>
|
||||
<p className="font-semibold text-emerald-900">{running.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-2xl font-bold text-emerald-900 tabular-nums">{fmtElapsed(elapsed)}</span>
|
||||
<Button variant="danger" onClick={stopTimer}>
|
||||
<Square className="h-4 w-4 fill-current" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader title="Today's totals" />
|
||||
<CardBody className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-ink-500">Hours tracked</p>
|
||||
<p className="text-3xl font-bold font-display text-ink-900 tabular-nums">{(totalMinutes / 60).toFixed(2)}h</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-ink-500">Billable amount</p>
|
||||
<p className="text-3xl font-bold font-display text-brand-600 tabular-nums">{formatMoney(totalAmount)}</p>
|
||||
</div>
|
||||
<div className="pt-3 border-t border-ink-100 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={exportCsv} disabled={!entries.length}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={clearAll} disabled={!entries.length}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader title="Entries" description={`${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}`} />
|
||||
{!entries.length ? (
|
||||
<EmptyState icon={<Clock className="h-5 w-5" />} title="Nothing logged yet" description="Start a timer above or add a manual entry." />
|
||||
) : (
|
||||
<ul className="divide-y divide-ink-100">
|
||||
{entries.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 truncate">
|
||||
{e.matter || 'No matter'} · {e.date}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-ink-900 tabular-nums">{(e.minutes / 60).toFixed(2)}h</p>
|
||||
<p className="text-xs text-ink-500">{formatMoney((e.minutes / 60) * e.rate)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEntry(e.id)}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-ink-400 hover:text-rose-600 hover:bg-rose-50"
|
||||
aria-label="Delete entry"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-brand-200 bg-brand-50/40 p-6 text-sm text-ink-700">
|
||||
<strong className="text-ink-900 font-semibold">Want this synced across devices and tied to client invoices?</strong>{' '}
|
||||
That's exactly what eLegal Software does.{' '}
|
||||
<Link to="/signup" className="font-semibold text-brand-600 hover:text-brand-700">
|
||||
Start free →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ArrowLeft, BarChart3 } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTrackTool } from '@/hooks/useToolUsage';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
|
||||
export default function CaseProfitabilityPage() {
|
||||
useTrackTool('case-profitability');
|
||||
|
||||
const [hours, setHours] = useState('40');
|
||||
const [rate, setRate] = useState('250');
|
||||
const [flatFee, setFlatFee] = useState('0');
|
||||
const [hardCosts, setHardCosts] = useState('500');
|
||||
const [overheadPct, setOverheadPct] = useState('25');
|
||||
|
||||
const r = useMemo(() => {
|
||||
const billed = Number(hours) * Number(rate) + Number(flatFee);
|
||||
const overhead = (billed * Number(overheadPct)) / 100;
|
||||
const profit = billed - Number(hardCosts) - overhead;
|
||||
const margin = billed > 0 ? (profit / billed) * 100 : 0;
|
||||
const effectiveRate = Number(hours) > 0 ? profit / Number(hours) : 0;
|
||||
return { billed, overhead, profit, margin, effectiveRate };
|
||||
}, [hours, rate, flatFee, hardCosts, overheadPct]);
|
||||
|
||||
const tone = r.margin >= 35 ? 'emerald' : r.margin >= 15 ? 'amber' : 'rose';
|
||||
const verdict = r.margin >= 35 ? 'Healthy' : r.margin >= 15 ? 'Tight' : r.margin >= 0 ? 'Marginal' : 'Losing money';
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="container py-12 max-w-4xl">
|
||||
<Link to="/tools" className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-6">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
All tools
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-xl bg-brand-50 text-brand-600">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-ink-950 font-display">Case Profitability Analyzer</h1>
|
||||
<p className="text-sm text-ink-600">See whether a matter is making you money once overhead and costs are in.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-5">
|
||||
<Card className="md:col-span-3">
|
||||
<CardHeader title="Case inputs" />
|
||||
<CardBody className="space-y-4">
|
||||
<Input label="Billable hours on this matter" type="number" min={0} step="0.5"
|
||||
value={hours} onChange={(e) => setHours(e.target.value)} />
|
||||
<Input label="Hourly rate (USD)" type="number" min={0} step="1"
|
||||
value={rate} onChange={(e) => setRate(e.target.value)} />
|
||||
<Input label="Flat fees collected (USD)" type="number" min={0} step="1"
|
||||
value={flatFee} onChange={(e) => setFlatFee(e.target.value)} hint="Set to 0 for purely hourly matters." />
|
||||
<Input label="Hard costs (filing fees, experts, etc.)" type="number" min={0} step="1"
|
||||
value={hardCosts} onChange={(e) => setHardCosts(e.target.value)} />
|
||||
<Input label="Overhead allocation (%)" type="number" min={0} max={100} step="1"
|
||||
value={overheadPct} onChange={(e) => setOverheadPct(e.target.value)}
|
||||
hint="Roughly: total firm overhead ÷ total billed revenue. 20–35% is typical for solos." />
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader title="Profitability" />
|
||||
<CardBody className="space-y-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs uppercase tracking-wider text-ink-500">Billed</span>
|
||||
<span className="text-lg font-semibold text-ink-900 tabular-nums">{formatMoney(r.billed)}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs uppercase tracking-wider text-ink-500">Hard costs</span>
|
||||
<span className="text-sm text-ink-700 tabular-nums">−{formatMoney(Number(hardCosts))}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs uppercase tracking-wider text-ink-500">Overhead share</span>
|
||||
<span className="text-sm text-ink-700 tabular-nums">−{formatMoney(r.overhead)}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between border-t border-ink-100 pt-3">
|
||||
<span className="text-xs uppercase tracking-wider text-ink-500">Profit</span>
|
||||
<span className="text-2xl font-bold font-display text-brand-600 tabular-nums">{formatMoney(r.profit)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge tone={tone}>{verdict}</Badge>
|
||||
<span className="text-sm text-ink-700 tabular-nums">{r.margin.toFixed(1)}% margin</span>
|
||||
</div>
|
||||
<p className="pt-3 border-t border-ink-100 text-xs text-ink-500">
|
||||
Effective hourly profit: <span className="text-ink-700 font-medium">{formatMoney(r.effectiveRate)}</span>
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ArrowLeft, FileText, Download, Copy, Check } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Input, Textarea } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useTrackTool } from '@/hooks/useToolUsage';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface TemplateField {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'date' | 'number' | 'textarea';
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
fields: TemplateField[];
|
||||
render: (v: Record<string, string>) => string;
|
||||
}
|
||||
|
||||
const TEMPLATES: Template[] = [
|
||||
{
|
||||
id: 'engagement-letter',
|
||||
title: 'Engagement Letter',
|
||||
description: 'A short letter confirming the scope, fees, and terms of representation.',
|
||||
fields: [
|
||||
{ key: 'firmName', label: 'Your firm name', defaultValue: 'Doe & Associates' },
|
||||
{ key: 'attorneyName', label: 'Attorney name', defaultValue: 'Jane Doe, Esq.' },
|
||||
{ key: 'clientName', label: 'Client name', placeholder: 'John Smith' },
|
||||
{ key: 'matter', label: 'Matter description', type: 'textarea', placeholder: 'Representation in the matter of …' },
|
||||
{ key: 'rate', label: 'Hourly rate (USD)', type: 'number', defaultValue: '250' },
|
||||
{ key: 'retainer', label: 'Initial retainer (USD)', type: 'number', defaultValue: '2500' },
|
||||
{ key: 'date', label: 'Date', type: 'date', defaultValue: new Date().toISOString().slice(0, 10) },
|
||||
],
|
||||
render: (v) => `${v.firmName}
|
||||
${v.date}
|
||||
|
||||
Re: Engagement of Legal Services
|
||||
|
||||
Dear ${v.clientName || '[Client]'},
|
||||
|
||||
Thank you for choosing ${v.firmName} to represent you. This letter confirms the scope and terms of our engagement.
|
||||
|
||||
1. Scope of Representation
|
||||
We will represent you in the following matter:
|
||||
${v.matter || '[Describe the matter being handled]'}
|
||||
|
||||
We will not represent you in any other matter unless we agree separately in writing.
|
||||
|
||||
2. Fees
|
||||
Our services will be billed at $${v.rate || '[Rate]'} per hour. You agree to pay an initial retainer of $${v.retainer || '[Retainer]'}, which will be applied to fees and costs as they are incurred. Invoices are due within thirty (30) days of receipt.
|
||||
|
||||
3. Costs
|
||||
You will be responsible for direct costs (filing fees, court reporters, expert witnesses, etc.). We will obtain your prior approval for any single expense exceeding $500.
|
||||
|
||||
4. Termination
|
||||
You may terminate our representation at any time by giving us written notice. We may withdraw with reasonable notice and consistent with our professional obligations.
|
||||
|
||||
5. Communication
|
||||
We will keep you informed of significant developments and respond to your inquiries promptly. Please direct your communications to the attorney named above.
|
||||
|
||||
If this letter accurately reflects our agreement, please sign below and return a copy to us.
|
||||
|
||||
Sincerely,
|
||||
|
||||
${v.attorneyName || '[Attorney name]'}
|
||||
${v.firmName}
|
||||
|
||||
Agreed and accepted:
|
||||
|
||||
____________________________
|
||||
${v.clientName || '[Client name]'} Date: ____________
|
||||
|
||||
— DRAFT TEMPLATE — Have a licensed attorney review before use.`,
|
||||
},
|
||||
{
|
||||
id: 'retainer-agreement',
|
||||
title: 'Retainer Agreement',
|
||||
description: 'A short retainer-fee agreement covering deposit, replenishment, and refund terms.',
|
||||
fields: [
|
||||
{ key: 'firmName', label: 'Your firm name', defaultValue: 'Doe & Associates' },
|
||||
{ key: 'clientName', label: 'Client name' },
|
||||
{ key: 'amount', label: 'Retainer amount (USD)', type: 'number', defaultValue: '5000' },
|
||||
{ key: 'minBalance', label: 'Replenish when balance falls below', type: 'number', defaultValue: '1000' },
|
||||
{ key: 'date', label: 'Date', type: 'date', defaultValue: new Date().toISOString().slice(0, 10) },
|
||||
],
|
||||
render: (v) => `RETAINER AGREEMENT
|
||||
|
||||
This Agreement is entered into on ${v.date} between ${v.firmName} ("Firm") and ${v.clientName || '[Client]'} ("Client").
|
||||
|
||||
1. Retainer Deposit. Client deposits $${v.amount || '[Amount]'} with Firm to secure availability of legal services. The deposit will be held in Firm's IOLTA trust account.
|
||||
|
||||
2. Application of Funds. Firm will draw against the deposit as fees and costs are incurred and billed. Client will receive a monthly statement showing time entries, costs, and remaining balance.
|
||||
|
||||
3. Replenishment. Whenever the trust balance falls below $${v.minBalance || '[Min]'}, Client agrees to replenish the deposit within ten (10) days of notice from Firm.
|
||||
|
||||
4. Refund. Any unused portion of the deposit will be returned to Client at the end of the engagement, after final billing.
|
||||
|
||||
5. No Guarantee of Outcome. The retainer secures Firm's availability and best efforts. It does not guarantee any particular result.
|
||||
|
||||
Firm: Client:
|
||||
|
||||
____________________________ ____________________________
|
||||
${v.firmName} ${v.clientName || '[Client name]'}
|
||||
Date: ____________ Date: ____________
|
||||
|
||||
— DRAFT TEMPLATE — Have a licensed attorney review before use.`,
|
||||
},
|
||||
{
|
||||
id: 'nda',
|
||||
title: 'Mutual Non-Disclosure Agreement',
|
||||
description: 'A simple two-party NDA for early-stage discussions.',
|
||||
fields: [
|
||||
{ key: 'partyA', label: 'Party A name' },
|
||||
{ key: 'partyB', label: 'Party B name' },
|
||||
{ key: 'purpose', label: 'Purpose of disclosure', type: 'textarea', placeholder: 'Discussion of a potential business relationship' },
|
||||
{ key: 'duration', label: 'Confidentiality period (years)', type: 'number', defaultValue: '3' },
|
||||
{ key: 'state', label: 'Governing law (state/country)', defaultValue: 'Delaware' },
|
||||
{ key: 'date', label: 'Effective date', type: 'date', defaultValue: new Date().toISOString().slice(0, 10) },
|
||||
],
|
||||
render: (v) => `MUTUAL NON-DISCLOSURE AGREEMENT
|
||||
|
||||
Effective Date: ${v.date}
|
||||
Between: ${v.partyA || '[Party A]'} and ${v.partyB || '[Party B]'} (each a "Party").
|
||||
|
||||
1. Purpose. The Parties wish to discuss the following matter (the "Purpose"):
|
||||
${v.purpose || '[Describe the purpose]'}
|
||||
|
||||
2. Confidential Information. "Confidential Information" means any non-public information disclosed by either Party to the other in connection with the Purpose, in any form, that is identified as confidential at the time of disclosure or that a reasonable person would understand to be confidential given its nature and the circumstances.
|
||||
|
||||
3. Obligations. Each Party will: (a) use the other Party's Confidential Information only for the Purpose; (b) protect it with at least the same care it uses for its own confidential information, and no less than reasonable care; and (c) not disclose it to any third party except to its own employees, advisors, and contractors who have a need to know and are bound by confidentiality obligations no less protective than this Agreement.
|
||||
|
||||
4. Exclusions. Confidential Information does not include information that: (a) is or becomes publicly known through no fault of the receiving Party; (b) was lawfully in the receiving Party's possession before disclosure; (c) is rightfully received from a third party without confidentiality obligations; or (d) is independently developed without use of the disclosing Party's Confidential Information.
|
||||
|
||||
5. Term. The obligations in this Agreement remain in effect for ${v.duration || '[N]'} years from the Effective Date.
|
||||
|
||||
6. No License. No license or other right is granted by either Party other than the limited right to use Confidential Information for the Purpose.
|
||||
|
||||
7. Return or Destruction. On request, each Party will promptly return or destroy the other Party's Confidential Information.
|
||||
|
||||
8. Governing Law. This Agreement is governed by the laws of ${v.state || '[State]'}, without regard to conflict-of-laws rules.
|
||||
|
||||
9. Entire Agreement. This Agreement is the entire understanding between the Parties regarding its subject matter and supersedes any prior agreements on that subject.
|
||||
|
||||
${v.partyA || '[Party A]'}: ${v.partyB || '[Party B]'}:
|
||||
|
||||
____________________________ ____________________________
|
||||
Name: Name:
|
||||
Title: Title:
|
||||
Date: Date:
|
||||
|
||||
— DRAFT TEMPLATE — Have a licensed attorney review before use.`,
|
||||
},
|
||||
{
|
||||
id: 'demand-letter',
|
||||
title: 'Demand Letter',
|
||||
description: 'A short, firm letter demanding payment or other action before litigation.',
|
||||
fields: [
|
||||
{ key: 'firmName', label: 'Your firm name', defaultValue: 'Doe & Associates' },
|
||||
{ key: 'attorneyName', label: 'Attorney name', defaultValue: 'Jane Doe, Esq.' },
|
||||
{ key: 'recipientName', label: 'Recipient name' },
|
||||
{ key: 'recipientAddress', label: 'Recipient address', type: 'textarea' },
|
||||
{ key: 'clientName', label: 'Your client' },
|
||||
{ key: 'amount', label: 'Amount demanded (USD)', type: 'number' },
|
||||
{ key: 'reason', label: 'Reason / underlying matter', type: 'textarea' },
|
||||
{ key: 'deadlineDays', label: 'Response deadline (days)', type: 'number', defaultValue: '14' },
|
||||
{ key: 'date', label: 'Date', type: 'date', defaultValue: new Date().toISOString().slice(0, 10) },
|
||||
],
|
||||
render: (v) => `${v.firmName}
|
||||
${v.date}
|
||||
|
||||
${v.recipientName || '[Recipient]'}
|
||||
${v.recipientAddress || '[Recipient address]'}
|
||||
|
||||
Re: Demand for Payment — ${v.clientName || '[Client]'}
|
||||
|
||||
Dear ${v.recipientName || '[Recipient]'},
|
||||
|
||||
This firm represents ${v.clientName || '[Client]'} in connection with the following matter:
|
||||
|
||||
${v.reason || '[Describe the underlying obligation, contract, or facts giving rise to the demand]'}
|
||||
|
||||
Despite repeated requests, this obligation remains unsatisfied. On behalf of our client, we hereby demand payment in the amount of $${v.amount || '[Amount]'} within ${v.deadlineDays || '14'} days of the date of this letter.
|
||||
|
||||
If we do not receive payment, or a satisfactory written response, by that date, our client has authorized us to pursue all available legal remedies, which may include filing suit and seeking interest, costs, and attorneys' fees as permitted by law.
|
||||
|
||||
Nothing in this letter is intended to waive any rights or remedies available to our client, all of which are expressly reserved.
|
||||
|
||||
We trust this matter can be resolved without further escalation. Please direct any response to the undersigned.
|
||||
|
||||
Sincerely,
|
||||
|
||||
${v.attorneyName || '[Attorney name]'}
|
||||
${v.firmName}
|
||||
|
||||
— DRAFT TEMPLATE — Have a licensed attorney review before use.`,
|
||||
},
|
||||
];
|
||||
|
||||
export default function DocumentTemplatesPage() {
|
||||
useTrackTool('document-templates');
|
||||
|
||||
const [activeId, setActiveId] = useState<string>(TEMPLATES[0]!.id);
|
||||
const active = TEMPLATES.find((t) => t.id === activeId)!;
|
||||
|
||||
const initial = useMemo(() => {
|
||||
const o: Record<string, string> = {};
|
||||
for (const f of active.fields) o[f.key] = f.defaultValue ?? '';
|
||||
return o;
|
||||
}, [active]);
|
||||
|
||||
const [values, setValues] = useState<Record<string, string>>(initial);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Reset values when switching template
|
||||
useMemo(() => setValues(initial), [initial]);
|
||||
|
||||
const rendered = active.render(values);
|
||||
|
||||
function download() {
|
||||
const blob = new Blob([rendered], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${active.id}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rendered);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="container py-12 max-w-6xl">
|
||||
<Link to="/tools" className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-6">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
All tools
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-xl bg-brand-50 text-brand-600">
|
||||
<FileText className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-ink-950 font-display">Document Templates</h1>
|
||||
<p className="text-sm text-ink-600">Plain-text starting points. Always have a licensed attorney review before use.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader title="Templates" />
|
||||
<ul className="p-2 space-y-1">
|
||||
{TEMPLATES.map((t) => (
|
||||
<li key={t.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveId(t.id)}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg px-3 py-2.5 text-sm transition',
|
||||
t.id === activeId
|
||||
? 'bg-brand-50 text-brand-700 font-medium'
|
||||
: 'text-ink-700 hover:bg-ink-50',
|
||||
)}
|
||||
>
|
||||
<p>{t.title}</p>
|
||||
<p className="text-xs text-ink-500 mt-0.5 line-clamp-2">{t.description}</p>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-4">
|
||||
<CardHeader title="Fill in the details" />
|
||||
<CardBody className="space-y-4">
|
||||
{active.fields.map((f) => (
|
||||
<RenderField
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={values[f.key] ?? ''}
|
||||
onChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
|
||||
/>
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-5">
|
||||
<CardHeader
|
||||
title="Preview"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={copy}>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={download}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
.txt
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
<pre className="whitespace-pre-wrap text-xs leading-relaxed text-ink-800 font-mono max-h-[500px] overflow-y-auto bg-ink-50/40 rounded-lg p-4 border border-ink-100">
|
||||
{rendered}
|
||||
</pre>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-amber-200 bg-amber-50/40 p-4 text-xs text-amber-900">
|
||||
<strong>Disclaimer:</strong> these templates are educational starting points, not legal
|
||||
advice. Laws vary by jurisdiction. Have a licensed attorney review and adapt before use.
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: TemplateField;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<Textarea
|
||||
label={field.label}
|
||||
rows={3}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
label={field.label}
|
||||
type={field.type ?? 'text'}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ArrowLeft, Calculator } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useTrackTool } from '@/hooks/useToolUsage';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
|
||||
export default function HourlyRateCalculatorPage() {
|
||||
useTrackTool('hourly-rate-calculator');
|
||||
|
||||
const [monthlyExpenses, setMonthlyExpenses] = useState('5000');
|
||||
const [desiredProfit, setDesiredProfit] = useState('8000');
|
||||
const [billableHoursPerWeek, setBillableHoursPerWeek] = useState('25');
|
||||
const [weeksPerYear, setWeeksPerYear] = useState('48');
|
||||
const [utilizationPct, setUtilizationPct] = useState('70');
|
||||
|
||||
const result = useMemo(() => {
|
||||
const annualTarget = (Number(monthlyExpenses) + Number(desiredProfit)) * 12;
|
||||
const grossHours = Number(billableHoursPerWeek) * Number(weeksPerYear);
|
||||
const realizedHours = (grossHours * Number(utilizationPct)) / 100;
|
||||
if (realizedHours <= 0) return null;
|
||||
const baseRate = annualTarget / realizedHours;
|
||||
return {
|
||||
annualTarget,
|
||||
realizedHours,
|
||||
baseRate,
|
||||
conservative: baseRate * 1.15, // padding for write-offs and admin
|
||||
premium: baseRate * 1.4, // premium positioning
|
||||
};
|
||||
}, [monthlyExpenses, desiredProfit, billableHoursPerWeek, weeksPerYear, utilizationPct]);
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="container py-12 max-w-4xl">
|
||||
<Link
|
||||
to="/tools"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
All tools
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-xl bg-brand-50 text-brand-600">
|
||||
<Calculator className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-ink-950 font-display">Hourly Rate Calculator</h1>
|
||||
<p className="text-sm text-ink-600">Find the rate you actually need to charge to hit your numbers.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-5">
|
||||
<Card className="md:col-span-3">
|
||||
<CardHeader title="Your inputs" />
|
||||
<CardBody className="space-y-4">
|
||||
<Input
|
||||
label="Monthly business expenses (USD)"
|
||||
type="number"
|
||||
min={0}
|
||||
step="100"
|
||||
value={monthlyExpenses}
|
||||
onChange={(e) => setMonthlyExpenses(e.target.value)}
|
||||
hint="Rent, software, malpractice insurance, staff, etc."
|
||||
/>
|
||||
<Input
|
||||
label="Desired monthly take-home profit (USD)"
|
||||
type="number"
|
||||
min={0}
|
||||
step="100"
|
||||
value={desiredProfit}
|
||||
onChange={(e) => setDesiredProfit(e.target.value)}
|
||||
hint="What you want to pay yourself, after expenses but before tax."
|
||||
/>
|
||||
<Input
|
||||
label="Billable hours per week (target)"
|
||||
type="number"
|
||||
min={1}
|
||||
max={80}
|
||||
value={billableHoursPerWeek}
|
||||
onChange={(e) => setBillableHoursPerWeek(e.target.value)}
|
||||
hint="Most attorneys realistically bill 20–30 hours weekly."
|
||||
/>
|
||||
<Input
|
||||
label="Weeks worked per year"
|
||||
type="number"
|
||||
min={1}
|
||||
max={52}
|
||||
value={weeksPerYear}
|
||||
onChange={(e) => setWeeksPerYear(e.target.value)}
|
||||
hint="52 minus vacations, holidays, and CLE time."
|
||||
/>
|
||||
<Input
|
||||
label="Utilization rate (%)"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={utilizationPct}
|
||||
onChange={(e) => setUtilizationPct(e.target.value)}
|
||||
hint="What share of those hours actually get billed and collected. 70% is common."
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader title="Recommended rates" />
|
||||
<CardBody className="space-y-4">
|
||||
{result ? (
|
||||
<>
|
||||
<Output label="Minimum to break even + profit" value={formatMoney(result.baseRate)} accent />
|
||||
<Output label="With 15% padding for write-offs" value={formatMoney(result.conservative)} />
|
||||
<Output label="Premium positioning (+40%)" value={formatMoney(result.premium)} />
|
||||
<div className="pt-3 border-t border-ink-100 text-xs text-ink-500 space-y-1">
|
||||
<p>Annual revenue target: {formatMoney(result.annualTarget)}</p>
|
||||
<p>Realized billable hours: {Math.round(result.realizedHours)} per year</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-ink-500">Enter your inputs to see suggested rates.</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-2xl border border-brand-200 bg-brand-50/40 p-6">
|
||||
<p className="text-sm text-ink-700">
|
||||
<strong className="font-semibold text-ink-900">Heads up:</strong> this is a quick model.
|
||||
It doesn't include taxes, retirement contributions, or one-time costs. For a fuller view
|
||||
of profitability per matter, try the{' '}
|
||||
<Link to="/tools/case-profitability" className="font-semibold text-brand-600 hover:text-brand-700">
|
||||
Case Profitability Analyzer
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function Output({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-ink-500">{label}</p>
|
||||
<p className={'mt-1 font-display font-bold ' + (accent ? 'text-3xl text-brand-600' : 'text-xl text-ink-900')}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Calculator, BarChart3, Clock, FileText } from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { PublicLayout, PublicHero } from '@/components/public/PublicLayout';
|
||||
import { useToolsOnline, type ToolName } from '@/hooks/useToolUsage';
|
||||
|
||||
interface Tool {
|
||||
slug: ToolName;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const TOOLS: Tool[] = [
|
||||
{
|
||||
slug: 'hourly-rate-calculator',
|
||||
title: 'Hourly Rate Calculator',
|
||||
description:
|
||||
'Work backwards from your target take-home to find the hourly rate you actually need to charge.',
|
||||
icon: Calculator,
|
||||
},
|
||||
{
|
||||
slug: 'case-profitability',
|
||||
title: 'Case Profitability Analyzer',
|
||||
description: 'Plug in hours, rates, and case expenses to see whether a matter is making you money.',
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
slug: 'billable-hours-tracker',
|
||||
title: 'Billable Hours Tracker',
|
||||
description: 'A no-signup timer with manual entries and CSV export. Saved locally to your browser.',
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
slug: 'document-templates',
|
||||
title: 'Document Templates',
|
||||
description: 'Fill in a few fields, download a clean text version of common law-firm documents.',
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
export default function ToolsIndexPage() {
|
||||
const online = useToolsOnline();
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<PublicHero
|
||||
eyebrow="Free Legal Tools"
|
||||
title="Practical calculators for your practice"
|
||||
description="No signup, no email. Use them as much as you like. If you want everything in one place, that's what eLegal Software is for."
|
||||
/>
|
||||
|
||||
<section className="container py-16">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{TOOLS.map((tool) => {
|
||||
const count = online.data?.online[tool.slug] ?? 0;
|
||||
return (
|
||||
<Link
|
||||
key={tool.slug}
|
||||
to={`/tools/${tool.slug}`}
|
||||
className="group rounded-2xl border border-ink-100 bg-white p-6 hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-xl bg-brand-50 text-brand-600 group-hover:bg-brand-500 group-hover:text-white transition">
|
||||
<tool.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 text-emerald-700 px-2 py-0.5 text-[11px] font-semibold">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
{count} online
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-5 text-lg font-semibold text-ink-900">{tool.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-ink-600">{tool.description}</p>
|
||||
<span className="mt-4 text-sm font-semibold text-brand-600 group-hover:text-brand-700">
|
||||
Open tool →
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user