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
+4
-6
@@ -22,13 +22,11 @@ DATABASE_URL=postgresql://doadmin:password@db-postgresql-nyc1-xxxxx.b.db.ondigit
|
|||||||
DATABASE_CA_CERT_PATH=./certs/do-ca.crt
|
DATABASE_CA_CERT_PATH=./certs/do-ca.crt
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# DigitalOcean Spaces (S3-compatible)
|
# Local file storage
|
||||||
|
# Absolute path where uploaded documents are stored (outside web root).
|
||||||
|
# Production example: /var/www/vhosts/elegalsoftware.com/storage
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com
|
STORAGE_PATH=./storage
|
||||||
SPACES_REGION=nyc3
|
|
||||||
SPACES_BUCKET=lawdesk-uploads
|
|
||||||
SPACES_ACCESS_KEY=
|
|
||||||
SPACES_SECRET_KEY=
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Email (Resend)
|
# Email (Resend)
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ Thumbs.db
|
|||||||
tmp/restart.txt
|
tmp/restart.txt
|
||||||
logs/
|
logs/
|
||||||
uploads/
|
uploads/
|
||||||
|
storage/
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const envSchema = z.object({
|
|||||||
WEB_DIST_PATH: z.string().optional(),
|
WEB_DIST_PATH: z.string().optional(),
|
||||||
SUPERADMIN_EMAILS: z.string().optional().default(''),
|
SUPERADMIN_EMAILS: z.string().optional().default(''),
|
||||||
SENTRY_DSN_API: z.string().optional().default(''),
|
SENTRY_DSN_API: z.string().optional().default(''),
|
||||||
|
STORAGE_PATH: z.string().min(1).default('./storage'),
|
||||||
RESEND_API_KEY: z.string().optional().default(''),
|
RESEND_API_KEY: z.string().optional().default(''),
|
||||||
EMAIL_FROM: z.string().optional().default('eLegal Software <noreply@elegalsoftware.com>'),
|
EMAIL_FROM: z.string().optional().default('eLegal Software <noreply@elegalsoftware.com>'),
|
||||||
STRIPE_SECRET_KEY: z.string().optional().default(''),
|
STRIPE_SECRET_KEY: z.string().optional().default(''),
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { env } from '../env';
|
||||||
|
|
||||||
|
function root(): string {
|
||||||
|
return path.resolve(env.STORAGE_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(key: string): string {
|
||||||
|
const abs = path.resolve(root(), key);
|
||||||
|
if (!abs.startsWith(root() + path.sep) && abs !== root()) {
|
||||||
|
throw new Error('invalid_storage_key');
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveFile(key: string, data: Buffer): Promise<void> {
|
||||||
|
const dest = resolve(key);
|
||||||
|
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
||||||
|
await fs.promises.writeFile(dest, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFile(key: string): Promise<void> {
|
||||||
|
await fs.promises.unlink(resolve(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReadStream(key: string): fs.ReadStream {
|
||||||
|
return fs.createReadStream(resolve(key));
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
|
import { getDb, documents, cases } from '@lawdesk/db';
|
||||||
|
import { saveFile, deleteFile, createReadStream } from '../lib/storage';
|
||||||
|
|
||||||
|
const ALLOWED_MIME = new Set([
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'text/plain',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
||||||
|
|
||||||
|
export async function documentsRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook('preHandler', app.requireFirm);
|
||||||
|
|
||||||
|
// ── List documents for a case ──────────────────────────────────────────────
|
||||||
|
app.get('/api/cases/:caseId/documents', async (req, reply) => {
|
||||||
|
const firmId = req.user!.firmId!;
|
||||||
|
const caseId = z.string().uuid().parse((req.params as { caseId: string }).caseId);
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const [c] = await db.select({ id: cases.id }).from(cases)
|
||||||
|
.where(and(eq(cases.id, caseId), eq(cases.firmId, firmId))).limit(1);
|
||||||
|
if (!c) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: documents.id,
|
||||||
|
name: documents.name,
|
||||||
|
mimeType: documents.mimeType,
|
||||||
|
sizeBytes: documents.sizeBytes,
|
||||||
|
createdAt: documents.createdAt,
|
||||||
|
})
|
||||||
|
.from(documents)
|
||||||
|
.where(and(eq(documents.caseId, caseId), eq(documents.firmId, firmId)))
|
||||||
|
.orderBy(desc(documents.createdAt));
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Upload a document ──────────────────────────────────────────────────────
|
||||||
|
app.post('/api/cases/:caseId/documents', async (req, reply) => {
|
||||||
|
const firmId = req.user!.firmId!;
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const caseId = z.string().uuid().parse((req.params as { caseId: string }).caseId);
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const [c] = await db.select({ id: cases.id }).from(cases)
|
||||||
|
.where(and(eq(cases.id, caseId), eq(cases.firmId, firmId))).limit(1);
|
||||||
|
if (!c) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
|
||||||
|
const data = await req.file({ limits: { fileSize: MAX_BYTES } });
|
||||||
|
if (!data) return reply.code(400).send({ error: 'no_file' });
|
||||||
|
if (!ALLOWED_MIME.has(data.mimetype)) {
|
||||||
|
return reply.code(400).send({ error: 'invalid_file_type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = await data.toBuffer();
|
||||||
|
const docId = randomUUID();
|
||||||
|
const ext = path.extname(data.filename);
|
||||||
|
const storageKey = `${firmId}/${caseId}/${docId}${ext}`;
|
||||||
|
|
||||||
|
await saveFile(storageKey, buf);
|
||||||
|
|
||||||
|
const [doc] = await db.insert(documents).values({
|
||||||
|
id: docId,
|
||||||
|
firmId,
|
||||||
|
caseId,
|
||||||
|
uploadedBy: userId,
|
||||||
|
name: data.filename,
|
||||||
|
storageKey,
|
||||||
|
mimeType: data.mimetype,
|
||||||
|
sizeBytes: buf.length,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return reply.code(201).send({
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
mimeType: doc.mimeType,
|
||||||
|
sizeBytes: doc.sizeBytes,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Download a document ────────────────────────────────────────────────────
|
||||||
|
app.get('/api/documents/:docId/download', async (req, reply) => {
|
||||||
|
const firmId = req.user!.firmId!;
|
||||||
|
const docId = z.string().uuid().parse((req.params as { docId: string }).docId);
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const [doc] = await db.select().from(documents)
|
||||||
|
.where(and(eq(documents.id, docId), eq(documents.firmId, firmId))).limit(1);
|
||||||
|
if (!doc) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
|
||||||
|
const stream = createReadStream(doc.storageKey);
|
||||||
|
return reply
|
||||||
|
.header('Content-Type', doc.mimeType)
|
||||||
|
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(doc.name)}"`)
|
||||||
|
.header('Content-Length', String(doc.sizeBytes))
|
||||||
|
.send(stream);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete a document ──────────────────────────────────────────────────────
|
||||||
|
app.delete('/api/documents/:docId', async (req, reply) => {
|
||||||
|
const firmId = req.user!.firmId!;
|
||||||
|
const docId = z.string().uuid().parse((req.params as { docId: string }).docId);
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const [doc] = await db.select().from(documents)
|
||||||
|
.where(and(eq(documents.id, docId), eq(documents.firmId, firmId))).limit(1);
|
||||||
|
if (!doc) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
|
||||||
|
await db.delete(documents).where(eq(documents.id, docId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteFile(doc.storageKey);
|
||||||
|
} catch (err) {
|
||||||
|
// DB row is already gone — log but don't fail the request.
|
||||||
|
req.log.warn({ err, storageKey: doc.storageKey }, 'orphaned file after delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -115,6 +115,16 @@ export async function invoicesRoutes(app: FastifyInstance) {
|
|||||||
return { items: rows, total: count?.total ?? 0 };
|
return { items: rows, total: count?.total ?? 0 };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Summary — outstanding total across sent + overdue invoices
|
||||||
|
app.get('/api/invoices/summary', async (req) => {
|
||||||
|
const firmId = req.user!.firmId!;
|
||||||
|
const [row] = await getDb()
|
||||||
|
.select({ outstanding: sql<string>`COALESCE(SUM(${invoices.total}), 0)` })
|
||||||
|
.from(invoices)
|
||||||
|
.where(and(eq(invoices.firmId, firmId), inArray(invoices.status, ['sent', 'overdue'])));
|
||||||
|
return { outstanding: row?.outstanding ?? '0' };
|
||||||
|
});
|
||||||
|
|
||||||
// Get with items
|
// Get with items
|
||||||
app.get('/api/invoices/:id', async (req, reply) => {
|
app.get('/api/invoices/:id', async (req, reply) => {
|
||||||
const firmId = req.user!.firmId!;
|
const firmId = req.user!.firmId!;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import cookie from '@fastify/cookie';
|
|||||||
import helmet from '@fastify/helmet';
|
import helmet from '@fastify/helmet';
|
||||||
import rateLimit from '@fastify/rate-limit';
|
import rateLimit from '@fastify/rate-limit';
|
||||||
import staticPlugin from '@fastify/static';
|
import staticPlugin from '@fastify/static';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
import { env, isProd } from './env';
|
import { env, isProd } from './env';
|
||||||
import { initSentry, captureError } from './lib/sentry';
|
import { initSentry, captureError } from './lib/sentry';
|
||||||
import { authPlugin } from './auth/plugin';
|
import { authPlugin } from './auth/plugin';
|
||||||
@@ -22,6 +23,7 @@ import { adminRoutes } from './routes/admin';
|
|||||||
import { accountRoutes } from './routes/account';
|
import { accountRoutes } from './routes/account';
|
||||||
import { toolUsageRoutes } from './routes/tool-usage';
|
import { toolUsageRoutes } from './routes/tool-usage';
|
||||||
import { billingRoutes } from './routes/billing';
|
import { billingRoutes } from './routes/billing';
|
||||||
|
import { documentsRoutes } from './routes/documents';
|
||||||
import { stripeWebhookRoute } from './routes/webhooks-stripe';
|
import { stripeWebhookRoute } from './routes/webhooks-stripe';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -82,6 +84,8 @@ export async function buildServer() {
|
|||||||
secret: env.SESSION_SECRET,
|
secret: env.SESSION_SECRET,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await app.register(multipart);
|
||||||
|
|
||||||
// Global rate limit floor — per-route limits override below.
|
// Global rate limit floor — per-route limits override below.
|
||||||
await app.register(rateLimit, {
|
await app.register(rateLimit, {
|
||||||
global: true,
|
global: true,
|
||||||
@@ -108,6 +112,7 @@ export async function buildServer() {
|
|||||||
await app.register(accountRoutes);
|
await app.register(accountRoutes);
|
||||||
await app.register(toolUsageRoutes);
|
await app.register(toolUsageRoutes);
|
||||||
await app.register(billingRoutes);
|
await app.register(billingRoutes);
|
||||||
|
await app.register(documentsRoutes);
|
||||||
|
|
||||||
// Serve the built SPA in production. In dev, the Vite dev server runs separately.
|
// Serve the built SPA in production. In dev, the Vite dev server runs separately.
|
||||||
const webDist = env.WEB_DIST_PATH ?? path.resolve(__dirname, '../../web/dist');
|
const webDist = env.WEB_DIST_PATH ?? path.resolve(__dirname, '../../web/dist');
|
||||||
|
|||||||
@@ -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}` : '';
|
return s ? `?${s}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useInvoiceSummary() {
|
||||||
|
return useQuery<{ outstanding: string }>({
|
||||||
|
queryKey: ['invoices', 'summary'],
|
||||||
|
queryFn: () => api.get('/api/invoices/summary'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useInvoices(params: ListParams = {}) {
|
export function useInvoices(params: ListParams = {}) {
|
||||||
return useQuery<ListResponse>({
|
return useQuery<ListResponse>({
|
||||||
queryKey: KEY.list(params),
|
queryKey: KEY.list(params),
|
||||||
|
|||||||
@@ -42,10 +42,27 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
|||||||
return data as T;
|
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 = {
|
export const api = {
|
||||||
get: <T>(path: string) => request<T>('GET', path),
|
get: <T>(path: string) => request<T>('GET', path),
|
||||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
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);
|
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 {
|
export function planLimitMessage(code: string | undefined, fallback = 'Action not allowed.'): string {
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case 'plan_limit_clients':
|
case 'plan_limit_clients':
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
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 { PageHeader } from '@/components/app/AppLayout';
|
||||||
import { Card, CardBody, CardHeader, EmptyState } from '@/components/ui/Card';
|
import { Card, CardBody, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -10,7 +10,8 @@ import { Badge } from '@/components/ui/Badge';
|
|||||||
import { CaseTimeList } from '@/components/app/CaseTimeList';
|
import { CaseTimeList } from '@/components/app/CaseTimeList';
|
||||||
import { CreateInvoiceDrawer } from '@/components/app/CreateInvoiceDrawer';
|
import { CreateInvoiceDrawer } from '@/components/app/CreateInvoiceDrawer';
|
||||||
import { useInvoices, type InvoiceStatus } from '@/hooks/useInvoices';
|
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 {
|
import {
|
||||||
useCase,
|
useCase,
|
||||||
useDeleteCase,
|
useDeleteCase,
|
||||||
@@ -36,6 +37,9 @@ export default function CaseDetailPage() {
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [invoiceDrawerOpen, setInvoiceDrawerOpen] = useState(false);
|
const [invoiceDrawerOpen, setInvoiceDrawerOpen] = useState(false);
|
||||||
const invoices = useInvoices(id ? { caseId: id } : undefined);
|
const invoices = useInvoices(id ? { caseId: id } : undefined);
|
||||||
|
const docs = useDocuments(id);
|
||||||
|
const uploadDoc = useUploadDocument(id ?? '');
|
||||||
|
const deleteDoc = useDeleteDocument(id ?? '');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -255,11 +259,77 @@ export default function CaseDetailPage() {
|
|||||||
<Card className="mt-6">
|
<Card className="mt-6">
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="Documents"
|
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>
|
{uploadDoc.isError && (
|
||||||
<p className="text-sm text-ink-500">Coming next.</p>
|
<div className="px-5 py-2 text-sm text-rose-600">
|
||||||
</CardBody>
|
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>
|
</Card>
|
||||||
|
|
||||||
<CreateInvoiceDrawer
|
<CreateInvoiceDrawer
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import { Badge } from '@/components/ui/Badge';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useCases } from '@/hooks/useCases';
|
import { useCases } from '@/hooks/useCases';
|
||||||
import { useClients } from '@/hooks/useClients';
|
import { useClients } from '@/hooks/useClients';
|
||||||
|
import { useInvoiceSummary } from '@/hooks/useInvoices';
|
||||||
import { useMe } from '@/hooks/useAuth';
|
import { useMe } from '@/hooks/useAuth';
|
||||||
import { formatHours, formatDate } from '@/lib/format';
|
import { formatHours, formatDate, formatMoney } from '@/lib/format';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
const cases = useCases();
|
const cases = useCases();
|
||||||
const clients = useClients();
|
const clients = useClients();
|
||||||
|
const summary = useInvoiceSummary();
|
||||||
|
|
||||||
const openCases = cases.data?.items.filter((c) => c.status === 'open') ?? [];
|
const openCases = cases.data?.items.filter((c) => c.status === 'open') ?? [];
|
||||||
const totalMinutes = cases.data?.items.reduce((acc, c) => acc + (c.billedMinutes ?? 0), 0) ?? 0;
|
const totalMinutes = cases.data?.items.reduce((acc, c) => acc + (c.billedMinutes ?? 0), 0) ?? 0;
|
||||||
@@ -51,7 +53,7 @@ export default function DashboardPage() {
|
|||||||
<KpiCard
|
<KpiCard
|
||||||
icon={<Receipt className="h-4 w-4" />}
|
icon={<Receipt className="h-4 w-4" />}
|
||||||
label="Outstanding"
|
label="Outstanding"
|
||||||
value="$0"
|
value={formatMoney(summary.data?.outstanding ?? 0)}
|
||||||
to="/app/invoices"
|
to="/app/invoices"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user