feat: complete Phase 3 — advanced features for Casera web app

Adds sharing (residence share codes, join, user management, .casera file
export/import), subscription status with feature comparison, notification
preferences with bell icon, profile settings (edit info, change password,
theme picker, delete account), onboarding wizard with create/join paths,
enhanced dashboard with stats cards, Recharts completion chart, recent
activity feed, and task report PDF download.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-03 09:31:29 -06:00
commit 5a50d77515
183 changed files with 34450 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
export * from './use-lookups';
export * from './use-auth';
export * from './use-residences';
export * from './use-tasks';
export * from './use-contractors';
export * from './use-documents';
export * from './use-notifications';
export * from './use-subscription';
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as authApi from '@/lib/api/auth';
import { useRouter } from 'next/navigation';
export function useCurrentUser() {
return useQuery({
queryKey: ['auth', 'user'],
queryFn: () => authApi.getCurrentUser(),
retry: false,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useLogout() {
const queryClient = useQueryClient();
const router = useRouter();
return useMutation({
mutationFn: () => authApi.logout(),
onSuccess: () => {
queryClient.clear();
router.push('/login');
},
});
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as contractorsApi from '@/lib/api/contractors';
import type { CreateContractorRequest, UpdateContractorRequest } from '@/lib/api/contractors';
// ---------------------------------------------------------------------------
// Query hooks
// ---------------------------------------------------------------------------
export function useContractors() {
return useQuery({
queryKey: ['contractors'],
queryFn: () => contractorsApi.listContractors(),
});
}
export function useContractor(id: number) {
return useQuery({
queryKey: ['contractors', id],
queryFn: () => contractorsApi.getContractor(id),
enabled: !!id,
});
}
export function useContractorTasks(id: number) {
return useQuery({
queryKey: ['contractors', id, 'tasks'],
queryFn: () => contractorsApi.getContractorTasks(id),
enabled: !!id,
});
}
// ---------------------------------------------------------------------------
// Mutation hooks
// ---------------------------------------------------------------------------
export function useCreateContractor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateContractorRequest) =>
contractorsApi.createContractor(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contractors'] });
},
});
}
export function useUpdateContractor(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateContractorRequest) =>
contractorsApi.updateContractor(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contractors'] });
queryClient.invalidateQueries({ queryKey: ['contractors', id] });
},
});
}
export function useDeleteContractor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => contractorsApi.deleteContractor(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contractors'] });
},
});
}
export function useToggleFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => contractorsApi.toggleFavorite(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contractors'] });
},
});
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as documentsApi from '@/lib/api/documents';
import type { DocumentListParams, CreateDocumentRequest, UpdateDocumentRequest } from '@/lib/api/documents';
// ---------------------------------------------------------------------------
// Query hooks
// ---------------------------------------------------------------------------
export function useDocuments(params?: DocumentListParams) {
return useQuery({ queryKey: ['documents', params], queryFn: () => documentsApi.listDocuments(params) });
}
export function useWarranties() {
return useQuery({ queryKey: ['documents', 'warranties'], queryFn: () => documentsApi.listWarranties() });
}
export function useDocument(id: number) {
return useQuery({ queryKey: ['documents', id], queryFn: () => documentsApi.getDocument(id), enabled: !!id });
}
// ---------------------------------------------------------------------------
// Mutation hooks
// ---------------------------------------------------------------------------
export function useCreateDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ data, file }: { data: CreateDocumentRequest; file?: File }) => {
if (file) {
return documentsApi.createDocumentWithFile(data, file);
}
return documentsApi.createDocument(data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] });
},
});
}
export function useUpdateDocument(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateDocumentRequest) =>
documentsApi.updateDocument(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] });
queryClient.invalidateQueries({ queryKey: ['documents', id] });
},
});
}
export function useDeleteDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => documentsApi.deleteDocument(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] });
},
});
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useQuery } from '@tanstack/react-query';
import * as lookupsApi from '@/lib/api/lookups';
// ---------------------------------------------------------------------------
// Main hook — fetches all static data in a single request
// ---------------------------------------------------------------------------
export function useLookups() {
return useQuery({
queryKey: ['lookups', 'static-data'],
queryFn: () => lookupsApi.getStaticData(),
staleTime: Infinity, // ETag-based; never auto-refetch
});
}
// ---------------------------------------------------------------------------
// Convenience hooks for individual lookup arrays
// ---------------------------------------------------------------------------
export function useTaskCategories() {
const { data, ...rest } = useLookups();
return { data: data?.task_categories ?? [], ...rest };
}
export function useTaskPriorities() {
const { data, ...rest } = useLookups();
return { data: data?.task_priorities ?? [], ...rest };
}
export function useTaskFrequencies() {
const { data, ...rest } = useLookups();
return { data: data?.task_frequencies ?? [], ...rest };
}
export function useContractorSpecialties() {
const { data, ...rest } = useLookups();
return { data: data?.contractor_specialties ?? [], ...rest };
}
export function useResidenceTypes() {
const { data, ...rest } = useLookups();
return { data: data?.residence_types ?? [], ...rest };
}
// ---------------------------------------------------------------------------
// O(1) lookup helpers — find a single item by ID from cached data
// ---------------------------------------------------------------------------
export function useTaskCategory(id: number | null | undefined) {
const { data: categories } = useTaskCategories();
if (!id) return null;
return categories.find((c) => c.id === id) ?? null;
}
export function useTaskPriority(id: number | null | undefined) {
const { data: priorities } = useTaskPriorities();
if (!id) return null;
return priorities.find((p) => p.id === id) ?? null;
}
export function useTaskFrequency(id: number | null | undefined) {
const { data: frequencies } = useTaskFrequencies();
if (!id) return null;
return frequencies.find((f) => f.id === id) ?? null;
}
export function useContractorSpecialty(id: number | null | undefined) {
const { data: specialties } = useContractorSpecialties();
if (!id) return null;
return specialties.find((s) => s.id === id) ?? null;
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as notificationsApi from '@/lib/api/notifications';
import type { UpdatePreferencesRequest } from '@/lib/api/notifications';
export function useNotifications(limit?: number) {
return useQuery({
queryKey: ['notifications', limit],
queryFn: () => notificationsApi.listNotifications(limit),
});
}
export function useUnreadCount() {
return useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: () => notificationsApi.getUnreadCount(),
refetchInterval: 60_000,
});
}
export function useNotificationPreferences() {
return useQuery({
queryKey: ['notifications', 'preferences'],
queryFn: () => notificationsApi.getPreferences(),
});
}
export function useUpdatePreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdatePreferencesRequest) => notificationsApi.updatePreferences(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications', 'preferences'] });
},
});
}
export function useMarkAsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => notificationsApi.markAsRead(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
},
});
}
export function useMarkAllAsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => notificationsApi.markAllAsRead(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
},
});
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as residencesApi from '@/lib/api/residences';
import type { CreateResidenceRequest, UpdateResidenceRequest } from '@/lib/api/residences';
// ---------------------------------------------------------------------------
// Query hooks
// ---------------------------------------------------------------------------
export function useResidences() {
return useQuery({
queryKey: ['residences'],
queryFn: () => residencesApi.getMyResidences(),
});
}
export function useResidence(id: number) {
return useQuery({
queryKey: ['residences', id],
queryFn: () => residencesApi.getResidence(id),
enabled: !!id,
});
}
// ---------------------------------------------------------------------------
// Mutation hooks
// ---------------------------------------------------------------------------
export function useCreateResidence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateResidenceRequest) =>
residencesApi.createResidence(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
export function useUpdateResidence(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateResidenceRequest) =>
residencesApi.updateResidence(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
queryClient.invalidateQueries({ queryKey: ['residences', id] });
},
});
}
export function useDeleteResidence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => residencesApi.deleteResidence(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as residencesApi from '@/lib/api/residences';
// ---------------------------------------------------------------------------
// Query hooks
// ---------------------------------------------------------------------------
export function useShareCode(residenceId: number) {
return useQuery({
queryKey: ['residences', residenceId, 'share-code'],
queryFn: () => residencesApi.getShareCode(residenceId),
enabled: !!residenceId,
});
}
export function useResidenceUsers(residenceId: number) {
return useQuery({
queryKey: ['residences', residenceId, 'users'],
queryFn: () => residencesApi.getResidenceUsers(residenceId),
enabled: !!residenceId,
});
}
// ---------------------------------------------------------------------------
// Mutation hooks
// ---------------------------------------------------------------------------
export function useGenerateShareCode(residenceId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => residencesApi.generateShareCode(residenceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences', residenceId, 'share-code'] });
},
});
}
export function useRemoveResidenceUser(residenceId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userId: number) => residencesApi.removeResidenceUser(residenceId, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences', residenceId, 'users'] });
},
});
}
export function useJoinResidence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (code: string) => residencesApi.joinWithCode({ code }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useQuery } from '@tanstack/react-query';
import * as subscriptionApi from '@/lib/api/subscription';
export function useSubscriptionStatus() {
return useQuery({
queryKey: ['subscription', 'status'],
queryFn: () => subscriptionApi.getSubscriptionStatus(),
});
}
export function useFeatureBenefits() {
return useQuery({
queryKey: ['subscription', 'features'],
queryFn: () => subscriptionApi.getFeatureBenefits(),
staleTime: Infinity,
});
}
export function useUpgradeTriggers() {
return useQuery({
queryKey: ['subscription', 'upgrade-triggers'],
queryFn: () => subscriptionApi.getAllUpgradeTriggers(),
staleTime: Infinity,
});
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as tasksApi from '@/lib/api/tasks';
import type { CreateTaskRequest, UpdateTaskRequest, CreateCompletionRequest } from '@/lib/api/tasks';
// ---------------------------------------------------------------------------
// Query hooks
// ---------------------------------------------------------------------------
export function useTasks() {
return useQuery({
queryKey: ['tasks'],
queryFn: () => tasksApi.listTasks(),
});
}
export function useTasksByResidence(residenceId: number) {
return useQuery({
queryKey: ['tasks', 'by-residence', residenceId],
queryFn: () => tasksApi.getTasksByResidence(residenceId),
enabled: !!residenceId,
});
}
export function useTask(id: number) {
return useQuery({
queryKey: ['tasks', id],
queryFn: () => tasksApi.getTask(id),
enabled: !!id,
});
}
export function useKanbanBoard(residenceId: number) {
return useQuery({
queryKey: ['tasks', 'kanban', residenceId],
queryFn: () => tasksApi.getTasksByResidence(residenceId),
enabled: !!residenceId,
});
}
export function useTaskCompletions(taskId: number) {
return useQuery({
queryKey: ['tasks', taskId, 'completions'],
queryFn: () => tasksApi.getTaskCompletions(taskId),
enabled: !!taskId,
});
}
// ---------------------------------------------------------------------------
// Mutation hooks
// ---------------------------------------------------------------------------
export function useCreateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTaskRequest) => tasksApi.createTask(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
export function useUpdateTask(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateTaskRequest) => tasksApi.updateTask(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['tasks', id] });
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
export function useDeleteTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => tasksApi.deleteTask(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
export function useMarkInProgress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => tasksApi.markInProgress(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useCancelTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => tasksApi.cancelTask(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useArchiveTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => tasksApi.archiveTask(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useCreateCompletion() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
data,
images,
}: {
data: CreateCompletionRequest;
images: File[];
}) => {
if (images.length > 0) {
return tasksApi.createCompletionWithImages(
{
task_id: data.task_id,
notes: data.notes,
actual_cost: data.actual_cost,
completed_at: data.completed_at,
},
images,
);
}
return tasksApi.createCompletion(data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}