Add document storage (local filesystem) + fix dashboard outstanding KPI
- Local file storage via STORAGE_PATH env var (replaces DO Spaces) - POST/GET/DELETE /api/cases/:caseId/documents + GET /api/documents/:docId/download - useDocuments hook + upload/list/download/delete UI in CaseDetailPage - GET /api/invoices/summary — live SUM of sent+overdue invoices - Dashboard outstanding KPI wired to real DB value - api.upload() for FormData, formatBytes() utility Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
0700d54225
commit
ce2278f4be
@@ -0,0 +1,40 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export interface Document {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const KEY = (caseId: string) => ['documents', caseId] as const;
|
||||
|
||||
export function useDocuments(caseId: string | undefined) {
|
||||
return useQuery<Document[]>({
|
||||
queryKey: caseId ? KEY(caseId) : ['documents', 'noop'],
|
||||
queryFn: () => api.get(`/api/cases/${caseId}/documents`),
|
||||
enabled: !!caseId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadDocument(caseId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<Document, ApiError, File>({
|
||||
mutationFn: (file) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return api.upload(`/api/cases/${caseId}/documents`, form);
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY(caseId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteDocument(caseId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, ApiError, string>({
|
||||
mutationFn: (docId) => api.delete(`/api/documents/${docId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY(caseId) }),
|
||||
});
|
||||
}
|
||||
@@ -90,6 +90,13 @@ function qs(p: ListParams): string {
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
export function useInvoiceSummary() {
|
||||
return useQuery<{ outstanding: string }>({
|
||||
queryKey: ['invoices', 'summary'],
|
||||
queryFn: () => api.get('/api/invoices/summary'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvoices(params: ListParams = {}) {
|
||||
return useQuery<ListResponse>({
|
||||
queryKey: KEY.list(params),
|
||||
|
||||
@@ -42,10 +42,27 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function upload<T>(path: string, form: FormData): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
const csrf = readCookie('csrf');
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||
const res = await fetch(path, { method: 'POST', credentials: 'same-origin', headers, body: form });
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const err = new Error(data?.error ?? `upload_failed_${res.status}`) as ApiError;
|
||||
err.status = res.status;
|
||||
err.code = data?.error;
|
||||
throw err;
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
upload: <T>(path: string, form: FormData) => upload<T>(path, form),
|
||||
};
|
||||
|
||||
@@ -16,6 +16,12 @@ export function formatMoney(amount: string | number | null | undefined): string
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(n);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function planLimitMessage(code: string | undefined, fallback = 'Action not allowed.'): string {
|
||||
switch (code) {
|
||||
case 'plan_limit_clients':
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { ArrowLeft, Plus, Receipt, Trash2 } from 'lucide-react';
|
||||
import { ArrowLeft, Download, FileText, Loader2, Plus, Receipt, Trash2, Upload } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/app/AppLayout';
|
||||
import { Card, CardBody, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -10,7 +10,8 @@ import { Badge } from '@/components/ui/Badge';
|
||||
import { CaseTimeList } from '@/components/app/CaseTimeList';
|
||||
import { CreateInvoiceDrawer } from '@/components/app/CreateInvoiceDrawer';
|
||||
import { useInvoices, type InvoiceStatus } from '@/hooks/useInvoices';
|
||||
import { formatDate, formatMoney } from '@/lib/format';
|
||||
import { formatBytes, formatDate, formatMoney } from '@/lib/format';
|
||||
import { useDocuments, useUploadDocument, useDeleteDocument } from '@/hooks/useDocuments';
|
||||
import {
|
||||
useCase,
|
||||
useDeleteCase,
|
||||
@@ -36,6 +37,9 @@ export default function CaseDetailPage() {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [invoiceDrawerOpen, setInvoiceDrawerOpen] = useState(false);
|
||||
const invoices = useInvoices(id ? { caseId: id } : undefined);
|
||||
const docs = useDocuments(id);
|
||||
const uploadDoc = useUploadDocument(id ?? '');
|
||||
const deleteDoc = useDeleteDocument(id ?? '');
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -255,11 +259,77 @@ export default function CaseDetailPage() {
|
||||
<Card className="mt-6">
|
||||
<CardHeader
|
||||
title="Documents"
|
||||
description="Document storage lands in the next iteration."
|
||||
description={docs.data?.length ? `${docs.data.length} file${docs.data.length === 1 ? '' : 's'}` : 'Upload contracts, filings, and correspondence.'}
|
||||
action={
|
||||
<label className="cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
className="sr-only"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.jpg,.jpeg,.png,.webp"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) uploadDoc.mutate(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
disabled={uploadDoc.isPending}
|
||||
/>
|
||||
<span className={`btn btn-secondary btn-sm inline-flex items-center gap-1.5 ${uploadDoc.isPending ? 'opacity-60 pointer-events-none' : ''}`}>
|
||||
{uploadDoc.isPending
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: <Upload className="h-3.5 w-3.5" />}
|
||||
{uploadDoc.isPending ? 'Uploading…' : 'Upload'}
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
<p className="text-sm text-ink-500">Coming next.</p>
|
||||
</CardBody>
|
||||
{uploadDoc.isError && (
|
||||
<div className="px-5 py-2 text-sm text-rose-600">
|
||||
Upload failed: {uploadDoc.error?.message}
|
||||
</div>
|
||||
)}
|
||||
{!docs.data?.length ? (
|
||||
<EmptyState
|
||||
icon={<FileText className="h-5 w-5" />}
|
||||
title="No documents yet"
|
||||
description="Upload PDF, Word, Excel, or image files up to 50 MB."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-ink-100">
|
||||
{docs.data.map((doc) => (
|
||||
<li key={doc.id} className="flex items-center justify-between gap-4 px-5 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<FileText className="h-4 w-4 shrink-0 text-ink-400" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-ink-900 truncate">{doc.name}</p>
|
||||
<p className="text-xs text-ink-500">
|
||||
{formatBytes(doc.sizeBytes)} · {formatDate(doc.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<a
|
||||
href={`/api/documents/${doc.id}/download`}
|
||||
download={doc.name}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-700"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${doc.name}"?`)) deleteDoc.mutate(doc.id);
|
||||
}}
|
||||
disabled={deleteDoc.isPending}
|
||||
className="text-ink-400 hover:text-rose-600 transition disabled:opacity-40"
|
||||
aria-label="Delete document"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<CreateInvoiceDrawer
|
||||
|
||||
@@ -6,13 +6,15 @@ import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useCases } from '@/hooks/useCases';
|
||||
import { useClients } from '@/hooks/useClients';
|
||||
import { useInvoiceSummary } from '@/hooks/useInvoices';
|
||||
import { useMe } from '@/hooks/useAuth';
|
||||
import { formatHours, formatDate } from '@/lib/format';
|
||||
import { formatHours, formatDate, formatMoney } from '@/lib/format';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const me = useMe();
|
||||
const cases = useCases();
|
||||
const clients = useClients();
|
||||
const summary = useInvoiceSummary();
|
||||
|
||||
const openCases = cases.data?.items.filter((c) => c.status === 'open') ?? [];
|
||||
const totalMinutes = cases.data?.items.reduce((acc, c) => acc + (c.billedMinutes ?? 0), 0) ?? 0;
|
||||
@@ -51,7 +53,7 @@ export default function DashboardPage() {
|
||||
<KpiCard
|
||||
icon={<Receipt className="h-4 w-4" />}
|
||||
label="Outstanding"
|
||||
value="$0"
|
||||
value={formatMoney(summary.data?.outstanding ?? 0)}
|
||||
to="/app/invoices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user