Initial commit — eLegal Software monorepo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user