# Phase 2 — Core CRUD Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build full CRUD for all 4 domains (Residences, Tasks, Contractors, Documents) with kanban board, form validation, file uploads, and proper cache invalidation. **Architecture:** Each domain is independent and can be built in parallel by separate agents. A shared foundation layer (reusable UI components + mutation hooks) is built first, then 4 domain agents run concurrently. Each agent produces a self-contained feature that compiles and renders independently. **Tech Stack:** Next.js 16 (App Router), TanStack Query v5, React Hook Form + Zod 4, shadcn/ui + Radix, Tailwind CSS 4, @dnd-kit (tasks kanban), Lucide icons. --- ## Execution Strategy: 6 Tasks, 4 Run in Parallel ``` Task 1: Shared Foundation (sequential — all domains depend on this) ↓ Task 2: Residences CRUD ─┐ Task 3: Tasks CRUD+Kanban ─┤ ← 4 parallel agents Task 4: Contractors CRUD ─┤ Task 5: Documents CRUD ─┘ ↓ Task 6: Integration + Verification (sequential — cross-domain wiring) ``` **Verification gate between layers:** Each task has a `npm run build` check. No task is complete until it compiles without errors. --- ## Task 1: Shared Foundation **Files:** - Create: `src/components/shared/page-header.tsx` - Create: `src/components/shared/empty-state.tsx` - Create: `src/components/shared/loading-skeleton.tsx` - Create: `src/components/shared/error-banner.tsx` - Create: `src/components/shared/confirm-dialog.tsx` - Create: `src/components/shared/form-field.tsx` - Create: `src/components/shared/lookup-select.tsx` - Create: `src/components/shared/file-upload.tsx` - Create: `src/components/shared/date-picker.tsx` - Create: `src/components/shared/currency-input.tsx` - Create: `src/components/shared/star-rating.tsx` - Create: `src/components/ui/dialog.tsx` (add via shadcn) - Create: `src/components/ui/select.tsx` (add via shadcn) - Create: `src/components/ui/textarea.tsx` (add via shadcn) - Create: `src/components/ui/tabs.tsx` (add via shadcn) - Create: `src/components/ui/skeleton.tsx` (add via shadcn) - Create: `src/components/ui/tooltip.tsx` (add via shadcn) - Create: `src/components/ui/popover.tsx` (add via shadcn) - Create: `src/components/ui/calendar.tsx` (add via shadcn) - Create: `src/lib/hooks/use-contractors.ts` - Create: `src/lib/hooks/use-documents.ts` - Modify: `src/lib/hooks/index.ts` (add new hook exports) ### Step 1: Install missing shadcn components and @dnd-kit ```bash cd myCribAPI-Web npx shadcn@latest add dialog select textarea tabs skeleton tooltip popover calendar npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities date-fns ``` ### Step 2: Create PageHeader component ```tsx // src/components/shared/page-header.tsx "use client"; import { Button } from "@/components/ui/button"; import { Plus } from "lucide-react"; interface PageHeaderProps { title: string; description?: string; actionLabel?: string; onAction?: () => void; children?: React.ReactNode; } export function PageHeader({ title, description, actionLabel, onAction, children, }: PageHeaderProps) { return (

{title}

{description && (

{description}

)}
{children} {actionLabel && onAction && ( )}
); } ``` ### Step 3: Create EmptyState component ```tsx // src/components/shared/empty-state.tsx import { Button } from "@/components/ui/button"; import { LucideIcon, Plus } from "lucide-react"; interface EmptyStateProps { icon: LucideIcon; title: string; description: string; actionLabel?: string; onAction?: () => void; } export function EmptyState({ icon: Icon, title, description, actionLabel, onAction, }: EmptyStateProps) { return (

{title}

{description}

{actionLabel && onAction && ( )}
); } ``` ### Step 4: Create LoadingSkeleton component ```tsx // src/components/shared/loading-skeleton.tsx import { Skeleton } from "@/components/ui/skeleton"; interface LoadingSkeletonProps { variant: "card-grid" | "list" | "detail" | "kanban"; count?: number; } export function LoadingSkeleton({ variant, count = 4 }: LoadingSkeletonProps) { if (variant === "card-grid") { return (
{Array.from({ length: count }).map((_, i) => (
))}
); } if (variant === "list") { return (
{Array.from({ length: count }).map((_, i) => (
))}
); } if (variant === "detail") { return (
{Array.from({ length: 6 }).map((_, i) => (
))}
); } // kanban return (
{Array.from({ length: count }).map((_, i) => (
))}
); } ``` ### Step 5: Create ErrorBanner component ```tsx // src/components/shared/error-banner.tsx "use client"; import { Button } from "@/components/ui/button"; import { AlertTriangle } from "lucide-react"; interface ErrorBannerProps { message?: string; onRetry?: () => void; } export function ErrorBanner({ message = "Something went wrong. Please try again.", onRetry, }: ErrorBannerProps) { return (

{message}

{onRetry && ( )}
); } ``` ### Step 6: Create ConfirmDialog component ```tsx // src/components/shared/confirm-dialog.tsx "use client"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; interface ConfirmDialogProps { open: boolean; onOpenChange: (open: boolean) => void; title: string; description: string; confirmLabel?: string; variant?: "default" | "destructive"; loading?: boolean; onConfirm: () => void; } export function ConfirmDialog({ open, onOpenChange, title, description, confirmLabel = "Confirm", variant = "default", loading = false, onConfirm, }: ConfirmDialogProps) { return ( {title} {description} ); } ``` ### Step 7: Create FormField wrapper ```tsx // src/components/shared/form-field.tsx import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; interface FormFieldProps { label: string; htmlFor: string; error?: string; required?: boolean; className?: string; children: React.ReactNode; } export function FormField({ label, htmlFor, error, required, className, children, }: FormFieldProps) { return (
{children} {error &&

{error}

}
); } ``` ### Step 8: Create LookupSelect component ```tsx // src/components/shared/lookup-select.tsx "use client"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; interface LookupItem { id: number; name: string; icon?: string; } interface LookupSelectProps { items: LookupItem[]; value?: number; onValueChange: (value: number | undefined) => void; placeholder?: string; disabled?: boolean; } export function LookupSelect({ items, value, onValueChange, placeholder = "Select...", disabled, }: LookupSelectProps) { return ( ); } ``` ### Step 9: Create FileUpload component ```tsx // src/components/shared/file-upload.tsx "use client"; import { useCallback, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Upload, X, FileIcon } from "lucide-react"; interface FileUploadProps { accept?: string; multiple?: boolean; maxSizeMB?: number; files: File[]; onChange: (files: File[]) => void; label?: string; } export function FileUpload({ accept = "image/*", multiple = false, maxSizeMB = 10, files, onChange, label = "Upload files", }: FileUploadProps) { const inputRef = useRef(null); const [error, setError] = useState(); const handleFiles = useCallback( (fileList: FileList) => { setError(undefined); const newFiles = Array.from(fileList); const oversized = newFiles.find( (f) => f.size > maxSizeMB * 1024 * 1024 ); if (oversized) { setError(`File "${oversized.name}" exceeds ${maxSizeMB}MB limit`); return; } onChange(multiple ? [...files, ...newFiles] : newFiles.slice(0, 1)); }, [files, maxSizeMB, multiple, onChange] ); const removeFile = (index: number) => { onChange(files.filter((_, i) => i !== index)); }; return (
inputRef.current?.click()} onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files); }} >

{label}

Drag & drop or click to browse (max {maxSizeMB}MB)

{ if (e.target.files?.length) handleFiles(e.target.files); e.target.value = ""; }} /> {error &&

{error}

} {files.length > 0 && (
    {files.map((file, i) => (
  • {file.name} {(file.size / 1024).toFixed(0)}KB
  • ))}
)}
); } ``` ### Step 10: Create CurrencyInput component ```tsx // src/components/shared/currency-input.tsx "use client"; import { Input } from "@/components/ui/input"; import { forwardRef } from "react"; interface CurrencyInputProps extends Omit, "value" | "onChange"> { value?: number; onChange: (value: number | undefined) => void; } export const CurrencyInput = forwardRef( function CurrencyInput({ value, onChange, ...props }, ref) { return (
$ { const v = e.target.value; onChange(v === "" ? undefined : Number(v)); }} {...props} />
); } ); ``` ### Step 11: Create StarRating component ```tsx // src/components/shared/star-rating.tsx "use client"; import { Star } from "lucide-react"; import { cn } from "@/lib/utils"; interface StarRatingProps { value: number; onChange?: (value: number) => void; readonly?: boolean; size?: "sm" | "md"; } export function StarRating({ value, onChange, readonly = false, size = "md", }: StarRatingProps) { const starSize = size === "sm" ? "size-4" : "size-5"; return (
{[1, 2, 3, 4, 5].map((star) => ( ))}
); } ``` ### Step 12: Create contractor and document query hooks ```typescript // src/lib/hooks/use-contractors.ts "use client"; import { useQuery } from '@tanstack/react-query'; import * as contractorsApi from '@/lib/api/contractors'; 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, }); } ``` ```typescript // src/lib/hooks/use-documents.ts "use client"; import { useQuery } from '@tanstack/react-query'; import * as documentsApi from '@/lib/api/documents'; import type { DocumentListParams } from '@/lib/api/documents'; 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, }); } ``` ### Step 13: Update hooks barrel export ```typescript // src/lib/hooks/index.ts export * from './use-lookups'; export * from './use-auth'; export * from './use-residences'; export * from './use-tasks'; export * from './use-contractors'; export * from './use-documents'; ``` ### Step 14: Verify build ```bash cd myCribAPI-Web && npm run build ``` Expected: Build succeeds with no type errors. ### Step 15: Commit ```bash git add src/components/shared/ src/components/ui/ src/lib/hooks/use-contractors.ts src/lib/hooks/use-documents.ts src/lib/hooks/index.ts package.json package-lock.json git commit -m "feat: add shared foundation components for Phase 2 CRUD Shared components: PageHeader, EmptyState, LoadingSkeleton, ErrorBanner, ConfirmDialog, FormField, LookupSelect, FileUpload, CurrencyInput, StarRating. shadcn additions: dialog, select, textarea, tabs, skeleton, tooltip, popover, calendar. New hooks: useContractors, useDocuments with TanStack Query. New deps: @dnd-kit/core, @dnd-kit/sortable, date-fns." ``` --- ## Task 2: Residences CRUD (Parallel Agent 1) **Depends on:** Task 1 (shared foundation) **Files:** - Create: `src/app/app/residences/page.tsx` (overwrite stub) - Create: `src/app/app/residences/new/page.tsx` - Create: `src/app/app/residences/[id]/page.tsx` - Create: `src/app/app/residences/[id]/edit/page.tsx` - Create: `src/components/residences/residence-card.tsx` - Create: `src/components/residences/residence-form.tsx` - Create: `src/components/residences/residence-summary.tsx` - Modify: `src/lib/hooks/use-residences.ts` (add mutation hooks) ### Step 1: Add mutation hooks to use-residences.ts Modify `src/lib/hooks/use-residences.ts`: ```typescript "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'; 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, }); } 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'] }); }, }); } ``` ### Step 2: Create ResidenceCard component ```tsx // src/components/residences/residence-card.tsx "use client"; import Link from "next/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Home, MapPin } from "lucide-react"; import type { MyResidenceResponse } from "@/lib/api/residences"; interface ResidenceCardProps { data: MyResidenceResponse; } export function ResidenceCard({ data }: ResidenceCardProps) { const { residence, task_summary } = data; return (
{residence.name}
{residence.street_address && (

{residence.street_address} {residence.city && `, ${residence.city}`}

)}
{task_summary.overdue > 0 && ( {task_summary.overdue} overdue )} {task_summary.due_soon > 0 && ( {task_summary.due_soon} due soon )} {task_summary.total} total tasks
); } ``` ### Step 3: Create ResidenceForm component ```tsx // src/components/residences/residence-form.tsx "use client"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { FormField } from "@/components/shared/form-field"; import { LookupSelect } from "@/components/shared/lookup-select"; import { useResidenceTypes } from "@/lib/hooks/use-lookups"; import type { ResidenceResponse } from "@/lib/api/residences"; const residenceSchema = z.object({ name: z.string().min(1, "Name is required"), property_type_id: z.number().optional(), street_address: z.string().optional(), apartment_unit: z.string().optional(), city: z.string().optional(), state_province: z.string().optional(), postal_code: z.string().optional(), country: z.string().optional(), bedrooms: z.number().min(0).optional(), bathrooms: z.number().min(0).optional(), square_footage: z.number().min(0).optional(), year_built: z.number().min(1800).max(2100).optional(), description: z.string().optional(), }); type ResidenceFormData = z.infer; interface ResidenceFormProps { residence?: ResidenceResponse; onSubmit: (data: ResidenceFormData) => void; loading?: boolean; } export function ResidenceForm({ residence, onSubmit, loading }: ResidenceFormProps) { const { data: residenceTypes } = useResidenceTypes(); const { register, handleSubmit, setValue, watch, formState: { errors }, } = useForm({ resolver: zodResolver(residenceSchema), defaultValues: residence ? { name: residence.name, property_type_id: residence.property_type_id ?? undefined, street_address: residence.street_address || undefined, apartment_unit: residence.apartment_unit || undefined, city: residence.city || undefined, state_province: residence.state_province || undefined, postal_code: residence.postal_code || undefined, country: residence.country || undefined, bedrooms: residence.bedrooms ?? undefined, bathrooms: residence.bathrooms ?? undefined, square_footage: residence.square_footage ?? undefined, year_built: residence.year_built ?? undefined, description: residence.description || undefined, } : {}, }); return (
setValue("property_type_id", v)} placeholder="Select type..." />