Initial commit — eLegal Software monorepo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export function useDeleteAccount() {
|
||||
return useMutation<void, ApiError, { password: string }>({
|
||||
mutationFn: (body) => api.post('/api/account/delete', body),
|
||||
});
|
||||
}
|
||||
|
||||
// Triggers a download of the export JSON. Uses a direct link so the browser handles the file save.
|
||||
export async function downloadAccountExport(): Promise<void> {
|
||||
const csrf = readCookie('csrf');
|
||||
const res = await fetch('/api/account/export', {
|
||||
credentials: 'same-origin',
|
||||
headers: csrf ? { 'X-CSRF-Token': csrf } : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(body || `export_failed_${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `lawdesk-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function readCookie(name: string): string | undefined {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
const prefix = `${name}=`;
|
||||
for (const part of document.cookie.split('; ')) {
|
||||
if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export type AdminPlan = 'starter' | 'pro' | 'lifetime';
|
||||
|
||||
export interface AdminStats {
|
||||
counters: {
|
||||
firms: number;
|
||||
users: number;
|
||||
cases: number;
|
||||
clients: number;
|
||||
invoices: number;
|
||||
unresolvedContact: number;
|
||||
paidRevenueTotal: string;
|
||||
};
|
||||
planDistribution: Array<{ plan: AdminPlan; count: number }>;
|
||||
signupsLast30Days: Array<{ day: string; count: number }>;
|
||||
}
|
||||
|
||||
export interface AdminFirmListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
plan: AdminPlan;
|
||||
watermarkEnabled: boolean;
|
||||
createdAt: string;
|
||||
userCount: number;
|
||||
caseCount: number;
|
||||
clientCount: number;
|
||||
paidTotal: string;
|
||||
}
|
||||
|
||||
export interface AdminFirmDetailResponse {
|
||||
firm: {
|
||||
id: string;
|
||||
name: string;
|
||||
plan: AdminPlan;
|
||||
watermarkEnabled: boolean;
|
||||
storageBytesUsed: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
trialEndsAt: string | null;
|
||||
stripeCustomerId: string | null;
|
||||
stripeSubscriptionId: string | null;
|
||||
};
|
||||
users: Array<{
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
role: string;
|
||||
isSuspended: boolean;
|
||||
isSuperadmin: boolean;
|
||||
createdAt: string;
|
||||
lastSeenAt: string | null;
|
||||
}>;
|
||||
counts: {
|
||||
clients: number;
|
||||
cases: number;
|
||||
invoices: number;
|
||||
paidTotal: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminUserListItem {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
role: string;
|
||||
isSuperadmin: boolean;
|
||||
isSuspended: boolean;
|
||||
createdAt: string;
|
||||
lastSeenAt: string | null;
|
||||
firmId: string | null;
|
||||
firmName: string | null;
|
||||
}
|
||||
|
||||
export interface AdminContactMessage {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
message: string;
|
||||
ip: string | null;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminAuditEntry {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
firmId: string | null;
|
||||
action: string;
|
||||
meta: string | null;
|
||||
ip: string | null;
|
||||
createdAt: string;
|
||||
userEmail: string | null;
|
||||
}
|
||||
|
||||
export function useAdminStats() {
|
||||
return useQuery<AdminStats>({
|
||||
queryKey: ['admin', 'stats'],
|
||||
queryFn: () => api.get('/api/admin/stats'),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminFirms(params: { q?: string; plan?: AdminPlan } = {}) {
|
||||
return useQuery<{ items: AdminFirmListItem[]; total: number }>({
|
||||
queryKey: ['admin', 'firms', params],
|
||||
queryFn: () => {
|
||||
const u = new URLSearchParams();
|
||||
if (params.q) u.set('q', params.q);
|
||||
if (params.plan) u.set('plan', params.plan);
|
||||
const s = u.toString();
|
||||
return api.get(`/api/admin/firms${s ? `?${s}` : ''}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminFirm(id: string | undefined) {
|
||||
return useQuery<AdminFirmDetailResponse>({
|
||||
queryKey: id ? ['admin', 'firms', 'detail', id] : ['admin', 'firms', 'detail', 'noop'],
|
||||
queryFn: () => api.get(`/api/admin/firms/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAdminFirm(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<unknown, ApiError, { plan?: AdminPlan; watermarkEnabled?: boolean; name?: string }>({
|
||||
mutationFn: (body) => api.patch(`/api/admin/firms/${id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminUsers(params: { q?: string; suspended?: 'true' | 'false' } = {}) {
|
||||
return useQuery<{ items: AdminUserListItem[]; total: number }>({
|
||||
queryKey: ['admin', 'users', params],
|
||||
queryFn: () => {
|
||||
const u = new URLSearchParams();
|
||||
if (params.q) u.set('q', params.q);
|
||||
if (params.suspended) u.set('suspended', params.suspended);
|
||||
const s = u.toString();
|
||||
return api.get(`/api/admin/users${s ? `?${s}` : ''}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAdminUser(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<unknown, ApiError, { isSuspended?: boolean; role?: string }>({
|
||||
mutationFn: (body) => api.patch(`/api/admin/users/${id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useImpersonate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<unknown, ApiError, string>({
|
||||
mutationFn: (id) => api.post(`/api/admin/users/${id}/impersonate`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminContactMessages(params: { resolved?: 'true' | 'false' } = {}) {
|
||||
return useQuery<{ items: AdminContactMessage[]; total: number }>({
|
||||
queryKey: ['admin', 'contact', params],
|
||||
queryFn: () => {
|
||||
const u = new URLSearchParams();
|
||||
if (params.resolved) u.set('resolved', params.resolved);
|
||||
const s = u.toString();
|
||||
return api.get(`/api/admin/contact-messages${s ? `?${s}` : ''}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolveContactMessage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<unknown, ApiError, { id: string; resolved: boolean }>({
|
||||
mutationFn: ({ id, resolved }) => api.patch(`/api/admin/contact-messages/${id}`, { resolved }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'contact'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuditLog(params: { action?: string; userId?: string; firmId?: string } = {}) {
|
||||
return useQuery<{ items: AdminAuditEntry[] }>({
|
||||
queryKey: ['admin', 'audit', params],
|
||||
queryFn: () => {
|
||||
const u = new URLSearchParams();
|
||||
if (params.action) u.set('action', params.action);
|
||||
if (params.userId) u.set('userId', params.userId);
|
||||
if (params.firmId) u.set('firmId', params.firmId);
|
||||
const s = u.toString();
|
||||
return api.get(`/api/admin/audit-log${s ? `?${s}` : ''}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName?: string | null;
|
||||
firmId: string | null;
|
||||
role: string;
|
||||
isSuperadmin?: boolean;
|
||||
isSuspended?: boolean;
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
const ME_KEY = ['auth', 'me'] as const;
|
||||
|
||||
export function useMe() {
|
||||
return useQuery<AuthUser | null>({
|
||||
queryKey: ME_KEY,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const data = await api.get<MeResponse>('/api/auth/me');
|
||||
return data.user;
|
||||
} catch (err) {
|
||||
if ((err as ApiError).status === 401) return null;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<AuthUser, ApiError, { email: string; password: string }>({
|
||||
mutationFn: async (vars) => {
|
||||
const data = await api.post<MeResponse>('/api/auth/login', vars);
|
||||
return data.user;
|
||||
},
|
||||
onSuccess: (user) => qc.setQueryData(ME_KEY, user),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSignup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<AuthUser, ApiError, { email: string; password: string; fullName: string; firmName: string }>({
|
||||
mutationFn: async (vars) => {
|
||||
const data = await api.post<MeResponse>('/api/auth/signup', vars);
|
||||
return data.user;
|
||||
},
|
||||
onSuccess: (user) => qc.setQueryData(ME_KEY, user),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, ApiError>({
|
||||
mutationFn: async () => {
|
||||
await api.post('/api/auth/logout');
|
||||
},
|
||||
onSuccess: () => qc.setQueryData(ME_KEY, null),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export interface BillingStatus {
|
||||
configured: boolean;
|
||||
plan: 'starter' | 'pro' | 'lifetime';
|
||||
hasSubscription: boolean;
|
||||
hasCustomer: boolean;
|
||||
}
|
||||
|
||||
export function useBillingStatus() {
|
||||
return useQuery<BillingStatus>({
|
||||
queryKey: ['billing', 'status'],
|
||||
queryFn: () => api.get('/api/billing/status'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartCheckout() {
|
||||
return useMutation<{ url: string }, ApiError, { plan: 'pro' | 'lifetime' }>({
|
||||
mutationFn: (body) => api.post('/api/billing/checkout', body),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpenPortal() {
|
||||
return useMutation<{ url: string }, ApiError, void>({
|
||||
mutationFn: () => api.post('/api/billing/portal'),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export type CaseStatus = 'open' | 'pending' | 'closed' | 'archived';
|
||||
|
||||
export interface CaseListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
caseNumber: string | null;
|
||||
status: CaseStatus;
|
||||
practiceArea: string | null;
|
||||
hourlyRate: string | null;
|
||||
openedAt: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
billedMinutes: number;
|
||||
}
|
||||
|
||||
export interface CaseDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
caseNumber: string | null;
|
||||
status: CaseStatus;
|
||||
practiceArea: string | null;
|
||||
description: string | null;
|
||||
hourlyRate: string | null;
|
||||
openedAt: string;
|
||||
closedAt: string | null;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
clientEmail: string | null;
|
||||
}
|
||||
|
||||
export interface CaseInput {
|
||||
clientId: string;
|
||||
title: string;
|
||||
caseNumber?: string | null;
|
||||
status?: CaseStatus;
|
||||
practiceArea?: string | null;
|
||||
description?: string | null;
|
||||
hourlyRate?: number | null;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
items: CaseListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ListParams {
|
||||
q?: string;
|
||||
status?: CaseStatus;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
const KEY = {
|
||||
list: (p: ListParams = {}) => ['cases', 'list', p] as const,
|
||||
detail: (id: string) => ['cases', 'detail', id] as const,
|
||||
};
|
||||
|
||||
function qs(p: ListParams): string {
|
||||
const u = new URLSearchParams();
|
||||
if (p.q) u.set('q', p.q);
|
||||
if (p.status) u.set('status', p.status);
|
||||
if (p.clientId) u.set('clientId', p.clientId);
|
||||
const s = u.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
export function useCases(params: ListParams = {}) {
|
||||
return useQuery<ListResponse>({
|
||||
queryKey: KEY.list(params),
|
||||
queryFn: () => api.get(`/api/cases${qs(params)}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCase(id: string | undefined) {
|
||||
return useQuery<CaseDetail>({
|
||||
queryKey: id ? KEY.detail(id) : ['cases', 'detail', 'noop'],
|
||||
queryFn: () => api.get(`/api/cases/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCase() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<CaseDetail, ApiError, CaseInput>({
|
||||
mutationFn: (body) => api.post('/api/cases', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['cases'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCase(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<CaseDetail, ApiError, Partial<CaseInput>>({
|
||||
mutationFn: (body) => api.patch(`/api/cases/${id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['cases'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCase() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, ApiError, string>({
|
||||
mutationFn: (id) => api.delete(`/api/cases/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['cases'] }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export interface ClientListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
createdAt: string;
|
||||
caseCount: number;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: string;
|
||||
firmId: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ClientInput {
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
address?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
items: ClientListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const KEY = {
|
||||
list: (q?: string) => ['clients', 'list', q ?? ''] as const,
|
||||
detail: (id: string) => ['clients', 'detail', id] as const,
|
||||
};
|
||||
|
||||
export function useClients(q?: string) {
|
||||
return useQuery<ListResponse>({
|
||||
queryKey: KEY.list(q),
|
||||
queryFn: () => api.get(`/api/clients${q ? `?q=${encodeURIComponent(q)}` : ''}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useClient(id: string | undefined) {
|
||||
return useQuery<Client>({
|
||||
queryKey: id ? KEY.detail(id) : ['clients', 'detail', 'noop'],
|
||||
queryFn: () => api.get(`/api/clients/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateClient() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<Client, ApiError, ClientInput>({
|
||||
mutationFn: (body) => api.post('/api/clients', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateClient(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<Client, ApiError, Partial<ClientInput>>({
|
||||
mutationFn: (body) => api.patch(`/api/clients/${id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['clients'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteClient() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, ApiError, string>({
|
||||
mutationFn: (id) => api.delete(`/api/clients/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'void';
|
||||
|
||||
export interface InvoiceListItem {
|
||||
id: string;
|
||||
number: string;
|
||||
status: InvoiceStatus;
|
||||
total: string;
|
||||
subtotal: string;
|
||||
issuedAt: string | null;
|
||||
dueAt: string | null;
|
||||
paidAt: string | null;
|
||||
createdAt: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
caseId: string | null;
|
||||
caseTitle: string | null;
|
||||
}
|
||||
|
||||
export interface InvoiceItem {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
description: string;
|
||||
quantity: string;
|
||||
rate: string;
|
||||
amount: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface InvoiceDetail {
|
||||
id: string;
|
||||
number: string;
|
||||
status: InvoiceStatus;
|
||||
subtotal: string;
|
||||
taxRate: string;
|
||||
total: string;
|
||||
notes: string | null;
|
||||
issuedAt: string | null;
|
||||
dueAt: string | null;
|
||||
paidAt: string | null;
|
||||
createdAt: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
clientEmail: string | null;
|
||||
caseId: string | null;
|
||||
caseTitle: string | null;
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
export interface CreateInvoiceInput {
|
||||
clientId: string;
|
||||
caseId?: string | null;
|
||||
notes?: string | null;
|
||||
taxRate?: number;
|
||||
dueAt?: string | null;
|
||||
items?: Array<{ description: string; quantity: number; rate: number }>;
|
||||
timeEntryIds?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateInvoiceInput {
|
||||
notes?: string | null;
|
||||
taxRate?: number;
|
||||
dueAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ListParams {
|
||||
status?: InvoiceStatus;
|
||||
clientId?: string;
|
||||
caseId?: string;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
items: InvoiceListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const KEY = {
|
||||
list: (p: ListParams = {}) => ['invoices', 'list', p] as const,
|
||||
detail: (id: string) => ['invoices', 'detail', id] as const,
|
||||
};
|
||||
|
||||
function qs(p: ListParams): string {
|
||||
const u = new URLSearchParams();
|
||||
if (p.status) u.set('status', p.status);
|
||||
if (p.clientId) u.set('clientId', p.clientId);
|
||||
if (p.caseId) u.set('caseId', p.caseId);
|
||||
const s = u.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
export function useInvoices(params: ListParams = {}) {
|
||||
return useQuery<ListResponse>({
|
||||
queryKey: KEY.list(params),
|
||||
queryFn: () => api.get(`/api/invoices${qs(params)}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvoice(id: string | undefined) {
|
||||
return useQuery<InvoiceDetail>({
|
||||
queryKey: id ? KEY.detail(id) : ['invoices', 'detail', 'noop'],
|
||||
queryFn: () => api.get(`/api/invoices/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateInvoice() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<InvoiceDetail, ApiError, CreateInvoiceInput>({
|
||||
mutationFn: (body) => api.post('/api/invoices', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['invoices'] });
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateInvoice(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<InvoiceDetail, ApiError, UpdateInvoiceInput>({
|
||||
mutationFn: (body) => api.patch(`/api/invoices/${id}`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['invoices'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendInvoice() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<InvoiceDetail, ApiError, string>({
|
||||
mutationFn: (id) => api.post(`/api/invoices/${id}/send`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['invoices'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkPaid() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<InvoiceDetail, ApiError, string>({
|
||||
mutationFn: (id) => api.post(`/api/invoices/${id}/mark-paid`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['invoices'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useVoidInvoice() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<InvoiceDetail, ApiError, string>({
|
||||
mutationFn: (id) => api.post(`/api/invoices/${id}/void`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['invoices'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteInvoice() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, ApiError, string>({
|
||||
mutationFn: (id) => api.delete(`/api/invoices/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['invoices'] });
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export function useRequestPasswordReset() {
|
||||
return useMutation<{ ok: boolean }, ApiError, { email: string }>({
|
||||
mutationFn: (body) => api.post('/api/auth/request-password-reset', body),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetPassword() {
|
||||
return useMutation<{ ok: boolean }, ApiError, { token: string; password: string }>({
|
||||
mutationFn: (body) => api.post('/api/auth/reset-password', body),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export interface TimeEntry {
|
||||
id: string;
|
||||
caseId: string;
|
||||
caseTitle: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
userId: string;
|
||||
description: string;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
minutes: number;
|
||||
rate: string;
|
||||
billable: boolean;
|
||||
invoiceItemId: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveTimer {
|
||||
id: string;
|
||||
caseId: string;
|
||||
caseTitle: string;
|
||||
clientName: string;
|
||||
description: string;
|
||||
startedAt: string;
|
||||
rate: string;
|
||||
}
|
||||
|
||||
export interface TimeListParams {
|
||||
caseId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
invoiced?: 'true' | 'false';
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
items: TimeEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const KEY = {
|
||||
list: (p: TimeListParams = {}) => ['time-entries', 'list', p] as const,
|
||||
active: ['time-entries', 'active'] as const,
|
||||
};
|
||||
|
||||
function qs(p: TimeListParams): string {
|
||||
const u = new URLSearchParams();
|
||||
if (p.caseId) u.set('caseId', p.caseId);
|
||||
if (p.from) u.set('from', p.from);
|
||||
if (p.to) u.set('to', p.to);
|
||||
if (p.invoiced) u.set('invoiced', p.invoiced);
|
||||
const s = u.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
export function useTimeEntries(params: TimeListParams = {}) {
|
||||
return useQuery<ListResponse>({
|
||||
queryKey: KEY.list(params),
|
||||
queryFn: () => api.get(`/api/time-entries${qs(params)}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useActiveTimer() {
|
||||
return useQuery<{ active: ActiveTimer | null }>({
|
||||
queryKey: KEY.active,
|
||||
queryFn: () => api.get('/api/time-entries/active'),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartTimer() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<TimeEntry, ApiError, { caseId: string; description?: string }>({
|
||||
mutationFn: (body) => api.post('/api/time-entries/start', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStopTimer() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<TimeEntry, ApiError, string>({
|
||||
mutationFn: (id) => api.post(`/api/time-entries/${id}/stop`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
qc.invalidateQueries({ queryKey: ['cases'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface ManualEntryInput {
|
||||
caseId: string;
|
||||
description: string;
|
||||
startedAt: string;
|
||||
endedAt?: string | null;
|
||||
minutes?: number;
|
||||
rate?: number;
|
||||
billable?: boolean;
|
||||
}
|
||||
|
||||
export function useCreateTimeEntry() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<TimeEntry, ApiError, ManualEntryInput>({
|
||||
mutationFn: (body) => api.post('/api/time-entries', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
qc.invalidateQueries({ queryKey: ['cases'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTimeEntry(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<TimeEntry, ApiError, Partial<ManualEntryInput>>({
|
||||
mutationFn: (body) => api.patch(`/api/time-entries/${id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
qc.invalidateQueries({ queryKey: ['cases'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTimeEntry() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, ApiError, string>({
|
||||
mutationFn: (id) => api.delete(`/api/time-entries/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
qc.invalidateQueries({ queryKey: ['cases'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export type ToolName =
|
||||
| 'hourly-rate-calculator'
|
||||
| 'case-profitability'
|
||||
| 'billable-hours-tracker'
|
||||
| 'document-templates';
|
||||
|
||||
const SESSION_KEY = 'lawdesk:tool-session';
|
||||
|
||||
function getSessionId(): string {
|
||||
if (typeof sessionStorage === 'undefined') return '';
|
||||
let v = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!v) {
|
||||
v = crypto.randomUUID();
|
||||
sessionStorage.setItem(SESSION_KEY, v);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Fire-and-forget: log a hit when a tool page mounts, then ping every 90 seconds
|
||||
// so the visitor counts as "online" until they leave the page.
|
||||
export function useTrackTool(tool: ToolName): void {
|
||||
useEffect(() => {
|
||||
const sessionId = getSessionId();
|
||||
const ping = () =>
|
||||
api.post('/api/tool-usage', { tool, sessionId }).catch(() => {});
|
||||
ping();
|
||||
const id = setInterval(ping, 90_000);
|
||||
return () => clearInterval(id);
|
||||
}, [tool]);
|
||||
}
|
||||
|
||||
export function useToolsOnline() {
|
||||
return useQuery<{ online: Record<string, number>; since: string }>({
|
||||
queryKey: ['tools', 'online'],
|
||||
queryFn: () => api.get('/api/tool-usage/online'),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user