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:
@@ -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';
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user