Files
honeyDueWeb/docs/plans/2026-03-02-core-crud.md
T
Trey t e2172c20f2 Rebrand from Casera/MyCrib to honeyDue
Total rebrand across Web project:
- Package name: casera-web -> honeydue-web
- Cookie: casera-token -> honeydue-token
- Theme store: casera-theme -> honeydue-theme
- File sharing: .casera -> .honeydue, component/function renames
- casera-file-handler.tsx -> honeydue-file-handler.tsx
- All UI text, metadata, OG tags updated
- Domains: casera.treytartt.com -> honeyDue.treytartt.com
- Demo data emails updated
- All documentation updated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:33:59 -06:00

79 KiB

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

cd honeyDueAPI-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

// 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 (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
      <div>
        <h1 className="text-2xl font-bold tracking-tight">{title}</h1>
        {description && (
          <p className="text-muted-foreground mt-1">{description}</p>
        )}
      </div>
      <div className="flex items-center gap-2">
        {children}
        {actionLabel && onAction && (
          <Button onClick={onAction}>
            <Plus className="size-4 mr-2" />
            {actionLabel}
          </Button>
        )}
      </div>
    </div>
  );
}

Step 3: Create EmptyState component

// 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 (
    <div className="flex flex-col items-center justify-center py-16 text-center">
      <div className="rounded-full bg-muted p-4 mb-4">
        <Icon className="size-8 text-muted-foreground" />
      </div>
      <h3 className="text-lg font-semibold">{title}</h3>
      <p className="text-muted-foreground mt-1 max-w-sm">{description}</p>
      {actionLabel && onAction && (
        <Button onClick={onAction} className="mt-4">
          <Plus className="size-4 mr-2" />
          {actionLabel}
        </Button>
      )}
    </div>
  );
}

Step 4: Create LoadingSkeleton component

// 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 (
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
        {Array.from({ length: count }).map((_, i) => (
          <div key={i} className="rounded-lg border p-4 space-y-3">
            <Skeleton className="h-5 w-3/4" />
            <Skeleton className="h-4 w-1/2" />
            <div className="flex gap-2 pt-2">
              <Skeleton className="h-6 w-16 rounded-full" />
              <Skeleton className="h-6 w-16 rounded-full" />
            </div>
          </div>
        ))}
      </div>
    );
  }

  if (variant === "list") {
    return (
      <div className="space-y-3">
        {Array.from({ length: count }).map((_, i) => (
          <div key={i} className="flex items-center gap-4 rounded-lg border p-4">
            <Skeleton className="size-10 rounded-full" />
            <div className="flex-1 space-y-2">
              <Skeleton className="h-4 w-1/3" />
              <Skeleton className="h-3 w-1/2" />
            </div>
            <Skeleton className="h-8 w-20" />
          </div>
        ))}
      </div>
    );
  }

  if (variant === "detail") {
    return (
      <div className="space-y-6">
        <Skeleton className="h-8 w-1/3" />
        <div className="grid grid-cols-2 gap-4">
          {Array.from({ length: 6 }).map((_, i) => (
            <div key={i} className="space-y-2">
              <Skeleton className="h-3 w-20" />
              <Skeleton className="h-5 w-full" />
            </div>
          ))}
        </div>
      </div>
    );
  }

  // kanban
  return (
    <div className="flex gap-4 overflow-x-auto pb-4">
      {Array.from({ length: count }).map((_, i) => (
        <div key={i} className="min-w-[280px] rounded-lg border p-4 space-y-3">
          <Skeleton className="h-5 w-24" />
          <Skeleton className="h-24 w-full rounded-md" />
          <Skeleton className="h-24 w-full rounded-md" />
        </div>
      ))}
    </div>
  );
}

Step 5: Create ErrorBanner component

// 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 (
    <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 flex items-center gap-3">
      <AlertTriangle className="size-5 text-destructive shrink-0" />
      <p className="text-sm text-destructive flex-1">{message}</p>
      {onRetry && (
        <Button variant="outline" size="sm" onClick={onRetry}>
          Retry
        </Button>
      )}
    </div>
  );
}

Step 6: Create ConfirmDialog component

// 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 (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription>{description}</DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
            Cancel
          </Button>
          <Button variant={variant} onClick={onConfirm} disabled={loading}>
            {loading ? "..." : confirmLabel}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

Step 7: Create FormField wrapper

// 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 (
    <div className={cn("space-y-2", className)}>
      <Label htmlFor={htmlFor}>
        {label}
        {required && <span className="text-destructive ml-1">*</span>}
      </Label>
      {children}
      {error && <p className="text-sm text-destructive">{error}</p>}
    </div>
  );
}

Step 8: Create LookupSelect component

// 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 (
    <Select
      value={value != null ? String(value) : undefined}
      onValueChange={(v) => onValueChange(v ? Number(v) : undefined)}
      disabled={disabled}
    >
      <SelectTrigger>
        <SelectValue placeholder={placeholder} />
      </SelectTrigger>
      <SelectContent>
        {items.map((item) => (
          <SelectItem key={item.id} value={String(item.id)}>
            {item.icon && <span className="mr-2">{item.icon}</span>}
            {item.name}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

Step 9: Create FileUpload component

// 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<HTMLInputElement>(null);
  const [error, setError] = useState<string>();

  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 (
    <div className="space-y-2">
      <div
        className="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors"
        onClick={() => inputRef.current?.click()}
        onDragOver={(e) => e.preventDefault()}
        onDrop={(e) => {
          e.preventDefault();
          if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
        }}
      >
        <Upload className="size-8 mx-auto text-muted-foreground mb-2" />
        <p className="text-sm text-muted-foreground">{label}</p>
        <p className="text-xs text-muted-foreground mt-1">
          Drag & drop or click to browse (max {maxSizeMB}MB)
        </p>
      </div>
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        multiple={multiple}
        className="hidden"
        onChange={(e) => {
          if (e.target.files?.length) handleFiles(e.target.files);
          e.target.value = "";
        }}
      />
      {error && <p className="text-sm text-destructive">{error}</p>}
      {files.length > 0 && (
        <ul className="space-y-1">
          {files.map((file, i) => (
            <li key={i} className="flex items-center gap-2 text-sm rounded border p-2">
              <FileIcon className="size-4 text-muted-foreground shrink-0" />
              <span className="flex-1 truncate">{file.name}</span>
              <span className="text-muted-foreground text-xs">
                {(file.size / 1024).toFixed(0)}KB
              </span>
              <Button
                variant="ghost"
                size="icon"
                className="size-6"
                onClick={(e) => {
                  e.stopPropagation();
                  removeFile(i);
                }}
              >
                <X className="size-3" />
              </Button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Step 10: Create CurrencyInput component

// src/components/shared/currency-input.tsx
"use client";

import { Input } from "@/components/ui/input";
import { forwardRef } from "react";

interface CurrencyInputProps
  extends Omit<React.ComponentProps<typeof Input>, "value" | "onChange"> {
  value?: number;
  onChange: (value: number | undefined) => void;
}

export const CurrencyInput = forwardRef<HTMLInputElement, CurrencyInputProps>(
  function CurrencyInput({ value, onChange, ...props }, ref) {
    return (
      <div className="relative">
        <span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">
          $
        </span>
        <Input
          ref={ref}
          type="number"
          step="0.01"
          min="0"
          className="pl-7"
          value={value ?? ""}
          onChange={(e) => {
            const v = e.target.value;
            onChange(v === "" ? undefined : Number(v));
          }}
          {...props}
        />
      </div>
    );
  }
);

Step 11: Create StarRating component

// 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 (
    <div className="flex gap-1">
      {[1, 2, 3, 4, 5].map((star) => (
        <button
          key={star}
          type="button"
          disabled={readonly}
          className={cn(
            "transition-colors",
            !readonly && "cursor-pointer hover:text-yellow-400"
          )}
          onClick={() => onChange?.(star === value ? 0 : star)}
        >
          <Star
            className={cn(
              starSize,
              star <= value
                ? "fill-yellow-400 text-yellow-400"
                : "text-muted-foreground"
            )}
          />
        </button>
      ))}
    </div>
  );
}

Step 12: Create contractor and document query hooks

// 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,
  });
}
// 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

// 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

cd honeyDueAPI-Web && npm run build

Expected: Build succeeds with no type errors.

Step 15: Commit

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:

"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

// 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 (
    <Link href={`/app/residences/${residence.id}`}>
      <Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
        <CardHeader className="pb-2">
          <div className="flex items-start justify-between">
            <CardTitle className="text-lg">{residence.name}</CardTitle>
            <Home className="size-5 text-muted-foreground shrink-0" />
          </div>
          {residence.street_address && (
            <p className="text-sm text-muted-foreground flex items-center gap-1">
              <MapPin className="size-3" />
              {residence.street_address}
              {residence.city && `, ${residence.city}`}
            </p>
          )}
        </CardHeader>
        <CardContent>
          <div className="flex flex-wrap gap-2">
            {task_summary.overdue > 0 && (
              <Badge variant="destructive">{task_summary.overdue} overdue</Badge>
            )}
            {task_summary.due_soon > 0 && (
              <Badge variant="secondary">{task_summary.due_soon} due soon</Badge>
            )}
            <Badge variant="outline">{task_summary.total} total tasks</Badge>
          </div>
        </CardContent>
      </Card>
    </Link>
  );
}

Step 3: Create ResidenceForm component

// 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<typeof residenceSchema>;

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<ResidenceFormData>({
    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 (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-2xl">
      <FormField label="Name" htmlFor="name" error={errors.name?.message} required>
        <Input id="name" {...register("name")} placeholder="My Home" />
      </FormField>

      <FormField label="Property Type" htmlFor="property_type_id" error={errors.property_type_id?.message}>
        <LookupSelect
          items={residenceTypes}
          value={watch("property_type_id")}
          onValueChange={(v) => setValue("property_type_id", v)}
          placeholder="Select type..."
        />
      </FormField>

      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
        <FormField label="Street Address" htmlFor="street_address">
          <Input id="street_address" {...register("street_address")} />
        </FormField>
        <FormField label="Apt / Unit" htmlFor="apartment_unit">
          <Input id="apartment_unit" {...register("apartment_unit")} />
        </FormField>
        <FormField label="City" htmlFor="city">
          <Input id="city" {...register("city")} />
        </FormField>
        <FormField label="State / Province" htmlFor="state_province">
          <Input id="state_province" {...register("state_province")} />
        </FormField>
        <FormField label="Postal Code" htmlFor="postal_code">
          <Input id="postal_code" {...register("postal_code")} />
        </FormField>
        <FormField label="Country" htmlFor="country">
          <Input id="country" {...register("country")} />
        </FormField>
      </div>

      <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
        <FormField label="Bedrooms" htmlFor="bedrooms">
          <Input id="bedrooms" type="number" min={0} {...register("bedrooms", { valueAsNumber: true })} />
        </FormField>
        <FormField label="Bathrooms" htmlFor="bathrooms">
          <Input id="bathrooms" type="number" min={0} step={0.5} {...register("bathrooms", { valueAsNumber: true })} />
        </FormField>
        <FormField label="Sq Ft" htmlFor="square_footage">
          <Input id="square_footage" type="number" min={0} {...register("square_footage", { valueAsNumber: true })} />
        </FormField>
        <FormField label="Year Built" htmlFor="year_built">
          <Input id="year_built" type="number" min={1800} max={2100} {...register("year_built", { valueAsNumber: true })} />
        </FormField>
      </div>

      <FormField label="Description" htmlFor="description">
        <Textarea id="description" rows={3} {...register("description")} />
      </FormField>

      <div className="flex gap-3">
        <Button type="submit" disabled={loading}>
          {loading ? "Saving..." : residence ? "Update Residence" : "Create Residence"}
        </Button>
      </div>
    </form>
  );
}

Step 4: Create ResidenceSummary component

// src/components/residences/residence-summary.tsx
"use client";

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ClipboardList, Wrench, FileText, Users } from "lucide-react";
import type { ResidenceResponse, TaskSummary } from "@/lib/api/residences";

interface ResidenceSummaryProps {
  residence: ResidenceResponse;
  taskSummary?: TaskSummary;
}

export function ResidenceSummary({ residence, taskSummary }: ResidenceSummaryProps) {
  const stats = [
    { label: "Total Tasks", value: taskSummary?.total ?? 0, icon: ClipboardList },
    { label: "In Progress", value: taskSummary?.in_progress ?? 0, icon: Wrench },
    { label: "Users", value: residence.user_count, icon: Users },
  ];

  return (
    <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
      {stats.map((stat) => (
        <Card key={stat.label}>
          <CardHeader className="pb-2">
            <CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
              <stat.icon className="size-4" />
              {stat.label}
            </CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-2xl font-bold">{stat.value}</p>
          </CardContent>
        </Card>
      ))}
    </div>
  );
}

Step 5: Build Residences List page

// src/app/app/residences/page.tsx  (overwrite stub)
"use client";

import { useRouter } from "next/navigation";
import { useResidences } from "@/lib/hooks/use-residences";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ResidenceCard } from "@/components/residences/residence-card";
import { Home } from "lucide-react";

export default function ResidencesPage() {
  const router = useRouter();
  const { data, isLoading, error, refetch } = useResidences();

  return (
    <div className="space-y-6">
      <PageHeader
        title="Residences"
        description="Manage your properties"
        actionLabel="Add Residence"
        onAction={() => router.push("/app/residences/new")}
      />

      {isLoading && <LoadingSkeleton variant="card-grid" />}
      {error && <ErrorBanner message={error.message} onRetry={() => refetch()} />}

      {data && data.length === 0 && (
        <EmptyState
          icon={Home}
          title="No residences yet"
          description="Add your first property to start tracking tasks, contractors, and documents."
          actionLabel="Add Residence"
          onAction={() => router.push("/app/residences/new")}
        />
      )}

      {data && data.length > 0 && (
        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
          {data.map((item) => (
            <ResidenceCard key={item.residence.id} data={item} />
          ))}
        </div>
      )}
    </div>
  );
}

Step 6: Build Create Residence page

// src/app/app/residences/new/page.tsx
"use client";

import { useRouter } from "next/navigation";
import { useCreateResidence } from "@/lib/hooks/use-residences";
import { PageHeader } from "@/components/shared/page-header";
import { ResidenceForm } from "@/components/residences/residence-form";

export default function CreateResidencePage() {
  const router = useRouter();
  const createMutation = useCreateResidence();

  return (
    <div className="space-y-6">
      <PageHeader title="New Residence" />
      <ResidenceForm
        loading={createMutation.isPending}
        onSubmit={(data) => {
          createMutation.mutate(data, {
            onSuccess: (res) => router.push(`/app/residences/${res.id}`),
          });
        }}
      />
    </div>
  );
}

Step 7: Build Residence Detail page

// src/app/app/residences/[id]/page.tsx
"use client";

import { use } from "react";
import { useRouter } from "next/navigation";
import { useResidence, useDeleteResidence } from "@/lib/hooks/use-residences";
import { useResidences } from "@/lib/hooks/use-residences";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ResidenceSummary } from "@/components/residences/residence-summary";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Pencil, Trash2, MapPin } from "lucide-react";
import { useState } from "react";

export default function ResidenceDetailPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = use(params);
  const residenceId = Number(id);
  const router = useRouter();
  const { data: residence, isLoading, error, refetch } = useResidence(residenceId);
  const { data: residences } = useResidences();
  const deleteMutation = useDeleteResidence();
  const [deleteOpen, setDeleteOpen] = useState(false);

  const taskSummary = residences?.find(
    (r) => r.residence.id === residenceId
  )?.task_summary;

  if (isLoading) return <LoadingSkeleton variant="detail" />;
  if (error) return <ErrorBanner message={error.message} onRetry={() => refetch()} />;
  if (!residence) return null;

  return (
    <div className="space-y-6">
      <PageHeader title={residence.name}>
        <Button variant="outline" onClick={() => router.push(`/app/residences/${residenceId}/edit`)}>
          <Pencil className="size-4 mr-2" /> Edit
        </Button>
        {residence.is_owner && (
          <Button variant="destructive" onClick={() => setDeleteOpen(true)}>
            <Trash2 className="size-4 mr-2" /> Delete
          </Button>
        )}
      </PageHeader>

      {residence.street_address && (
        <p className="text-muted-foreground flex items-center gap-1">
          <MapPin className="size-4" />
          {[residence.street_address, residence.city, residence.state_province, residence.postal_code]
            .filter(Boolean)
            .join(", ")}
        </p>
      )}

      <ResidenceSummary residence={residence} taskSummary={taskSummary} />

      {residence.description && (
        <Card>
          <CardHeader><CardTitle className="text-sm">Description</CardTitle></CardHeader>
          <CardContent><p className="text-sm">{residence.description}</p></CardContent>
        </Card>
      )}

      <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
        {residence.bedrooms != null && (
          <div><span className="text-muted-foreground">Bedrooms</span><p className="font-medium">{residence.bedrooms}</p></div>
        )}
        {residence.bathrooms != null && (
          <div><span className="text-muted-foreground">Bathrooms</span><p className="font-medium">{residence.bathrooms}</p></div>
        )}
        {residence.square_footage != null && (
          <div><span className="text-muted-foreground">Sq Ft</span><p className="font-medium">{residence.square_footage.toLocaleString()}</p></div>
        )}
        {residence.year_built != null && (
          <div><span className="text-muted-foreground">Year Built</span><p className="font-medium">{residence.year_built}</p></div>
        )}
      </div>

      <ConfirmDialog
        open={deleteOpen}
        onOpenChange={setDeleteOpen}
        title="Delete Residence"
        description={`Are you sure you want to delete "${residence.name}"? This cannot be undone.`}
        confirmLabel="Delete"
        variant="destructive"
        loading={deleteMutation.isPending}
        onConfirm={() => {
          deleteMutation.mutate(residenceId, {
            onSuccess: () => router.push("/app/residences"),
          });
        }}
      />
    </div>
  );
}

Step 8: Build Edit Residence page

// src/app/app/residences/[id]/edit/page.tsx
"use client";

import { use } from "react";
import { useRouter } from "next/navigation";
import { useResidence, useUpdateResidence } from "@/lib/hooks/use-residences";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ResidenceForm } from "@/components/residences/residence-form";

export default function EditResidencePage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = use(params);
  const residenceId = Number(id);
  const router = useRouter();
  const { data: residence, isLoading, error, refetch } = useResidence(residenceId);
  const updateMutation = useUpdateResidence(residenceId);

  if (isLoading) return <LoadingSkeleton variant="detail" />;
  if (error) return <ErrorBanner message={error.message} onRetry={() => refetch()} />;
  if (!residence) return null;

  return (
    <div className="space-y-6">
      <PageHeader title={`Edit ${residence.name}`} />
      <ResidenceForm
        residence={residence}
        loading={updateMutation.isPending}
        onSubmit={(data) => {
          updateMutation.mutate(data, {
            onSuccess: () => router.push(`/app/residences/${residenceId}`),
          });
        }}
      />
    </div>
  );
}

Step 9: Verify build

cd honeyDueAPI-Web && npm run build

Expected: Build succeeds. All 4 residence routes render.

Step 10: Commit

git add src/app/app/residences/ src/components/residences/ src/lib/hooks/use-residences.ts
git commit -m "feat: add Residences CRUD — list, detail, create, edit, delete"

Task 3: Tasks CRUD + Kanban Board (Parallel Agent 2)

Depends on: Task 1 (shared foundation)

Files:

  • Create: src/app/app/tasks/page.tsx (overwrite stub)
  • Create: src/app/app/tasks/new/page.tsx
  • Create: src/app/app/tasks/[id]/page.tsx
  • Create: src/app/app/tasks/[id]/edit/page.tsx
  • Create: src/app/app/tasks/[id]/complete/page.tsx
  • Create: src/components/tasks/kanban-board.tsx
  • Create: src/components/tasks/kanban-column.tsx
  • Create: src/components/tasks/task-card.tsx
  • Create: src/components/tasks/task-form.tsx
  • Create: src/components/tasks/task-completion-form.tsx
  • Create: src/components/tasks/task-actions-menu.tsx
  • Create: src/components/tasks/template-search.tsx
  • Modify: src/lib/hooks/use-tasks.ts (add mutation hooks)

Step 1: Add mutation hooks to use-tasks.ts

Modify src/lib/hooks/use-tasks.ts:

"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';

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 useTaskCompletions(taskId: number) {
  return useQuery({
    queryKey: ['tasks', taskId, 'completions'],
    queryFn: () => tasksApi.getTaskCompletions(taskId),
    enabled: !!taskId,
  });
}

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: { task_id: number; notes?: string; actual_cost?: number; completed_at?: string }; images: File[] }) =>
      images.length > 0
        ? tasksApi.createCompletionWithImages(data, images)
        : tasksApi.createCompletion(data as CreateCompletionRequest),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['tasks'] });
      queryClient.invalidateQueries({ queryKey: ['residences'] });
    },
  });
}

Step 2: Create TaskCard component

// src/components/tasks/task-card.tsx
"use client";

import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Calendar, DollarSign } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TaskResponse } from "@/lib/api/tasks";

interface TaskCardProps {
  task: TaskResponse;
  isDragging?: boolean;
}

export function TaskCard({ task, isDragging }: TaskCardProps) {
  return (
    <Link href={`/app/tasks/${task.id}`}>
      <Card className={cn(
        "hover:shadow-md transition-shadow cursor-pointer",
        isDragging && "shadow-lg ring-2 ring-primary"
      )}>
        <CardContent className="p-3 space-y-2">
          <p className="font-medium text-sm leading-tight">{task.title}</p>
          <p className="text-xs text-muted-foreground">{task.residence_name}</p>
          <div className="flex flex-wrap gap-1">
            {task.priority && (
              <Badge variant="outline" className="text-xs">
                {task.priority.icon} {task.priority.name}
              </Badge>
            )}
            {task.category && (
              <Badge variant="secondary" className="text-xs">
                {task.category.icon} {task.category.name}
              </Badge>
            )}
          </div>
          <div className="flex items-center gap-3 text-xs text-muted-foreground">
            {task.due_date && (
              <span className="flex items-center gap-1">
                <Calendar className="size-3" />
                {new Date(task.due_date).toLocaleDateString()}
              </span>
            )}
            {task.estimated_cost != null && (
              <span className="flex items-center gap-1">
                <DollarSign className="size-3" />
                ${task.estimated_cost}
              </span>
            )}
          </div>
        </CardContent>
      </Card>
    </Link>
  );
}

Step 3: Create KanbanColumn component

// src/components/tasks/kanban-column.tsx
"use client";

import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Badge } from "@/components/ui/badge";
import { TaskCard } from "./task-card";
import { cn } from "@/lib/utils";
import type { KanbanColumn as KanbanColumnType } from "@/lib/api/tasks";

const columnColors: Record<string, string> = {
  overdue: "bg-red-500",
  due_today: "bg-orange-500",
  due_soon: "bg-yellow-500",
  upcoming: "bg-blue-500",
  in_progress: "bg-green-500",
  completed: "bg-gray-400",
};

interface SortableTaskProps {
  task: { id: number; [key: string]: unknown };
}

function SortableTask({ task }: SortableTaskProps) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id: `task-${task.id}`,
  });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };

  return (
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
      <TaskCard task={task as never} isDragging={isDragging} />
    </div>
  );
}

interface KanbanColumnProps {
  column: KanbanColumnType;
}

export function KanbanColumn({ column }: KanbanColumnProps) {
  const { setNodeRef, isOver } = useDroppable({
    id: column.name,
  });

  const colorClass = columnColors[column.name] ?? "bg-gray-400";

  return (
    <div
      ref={setNodeRef}
      className={cn(
        "min-w-[280px] max-w-[320px] flex-shrink-0 rounded-lg border bg-card p-3",
        isOver && "ring-2 ring-primary/50"
      )}
    >
      <div className="flex items-center gap-2 mb-3">
        <div className={cn("size-2.5 rounded-full", colorClass)} />
        <h3 className="font-semibold text-sm">{column.display_name}</h3>
        <Badge variant="secondary" className="text-xs ml-auto">
          {column.count}
        </Badge>
      </div>

      <SortableContext
        items={column.tasks.map((t) => `task-${t.id}`)}
        strategy={verticalListSortingStrategy}
      >
        <div className="space-y-2 min-h-[100px]">
          {column.tasks.map((task) => (
            <SortableTask key={task.id} task={task} />
          ))}
        </div>
      </SortableContext>
    </div>
  );
}

Step 4: Create KanbanBoard component

// src/components/tasks/kanban-board.tsx
"use client";

import { useCallback } from "react";
import {
  DndContext,
  DragEndEvent,
  PointerSensor,
  useSensor,
  useSensors,
  closestCorners,
} from "@dnd-kit/core";
import { KanbanColumn } from "./kanban-column";
import { useMarkInProgress } from "@/lib/hooks/use-tasks";
import { useRouter } from "next/navigation";
import type { KanbanResponse } from "@/lib/api/tasks";

interface KanbanBoardProps {
  data: KanbanResponse;
}

export function KanbanBoard({ data }: KanbanBoardProps) {
  const router = useRouter();
  const markInProgress = useMarkInProgress();
  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
  );

  const handleDragEnd = useCallback(
    (event: DragEndEvent) => {
      const { active, over } = event;
      if (!over) return;

      const taskId = Number(String(active.id).replace("task-", ""));
      const targetColumn = String(over.id);

      if (targetColumn === "in_progress") {
        markInProgress.mutate(taskId);
      } else if (targetColumn === "completed") {
        router.push(`/app/tasks/${taskId}/complete`);
      }
    },
    [markInProgress, router]
  );

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCorners}
      onDragEnd={handleDragEnd}
    >
      <div className="flex gap-4 overflow-x-auto pb-4">
        {data.columns.map((column) => (
          <KanbanColumn key={column.name} column={column} />
        ))}
      </div>
    </DndContext>
  );
}

Step 5: Create TaskActionsMenu component

// src/components/tasks/task-actions-menu.tsx
"use client";

import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal, CheckCircle, Play, XCircle, Archive, Undo, Pencil, Trash2 } from "lucide-react";
import { useCancelTask, useArchiveTask, useMarkInProgress, useDeleteTask } from "@/lib/hooks/use-tasks";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { useState } from "react";
import type { TaskResponse } from "@/lib/api/tasks";

interface TaskActionsMenuProps {
  task: TaskResponse;
}

export function TaskActionsMenu({ task }: TaskActionsMenuProps) {
  const router = useRouter();
  const cancelMutation = useCancelTask();
  const archiveMutation = useArchiveTask();
  const markInProgressMutation = useMarkInProgress();
  const deleteMutation = useDeleteTask();
  const [deleteOpen, setDeleteOpen] = useState(false);

  return (
    <>
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button variant="ghost" size="icon"><MoreHorizontal className="size-4" /></Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end">
          <DropdownMenuItem onClick={() => router.push(`/app/tasks/${task.id}/complete`)}>
            <CheckCircle className="size-4 mr-2" /> Complete
          </DropdownMenuItem>
          {!task.in_progress && (
            <DropdownMenuItem onClick={() => markInProgressMutation.mutate(task.id)}>
              <Play className="size-4 mr-2" /> Mark In Progress
            </DropdownMenuItem>
          )}
          <DropdownMenuItem onClick={() => router.push(`/app/tasks/${task.id}/edit`)}>
            <Pencil className="size-4 mr-2" /> Edit
          </DropdownMenuItem>
          <DropdownMenuSeparator />
          {!task.is_cancelled && (
            <DropdownMenuItem onClick={() => cancelMutation.mutate(task.id)}>
              <XCircle className="size-4 mr-2" /> Cancel
            </DropdownMenuItem>
          )}
          {!task.is_archived && (
            <DropdownMenuItem onClick={() => archiveMutation.mutate(task.id)}>
              <Archive className="size-4 mr-2" /> Archive
            </DropdownMenuItem>
          )}
          <DropdownMenuSeparator />
          <DropdownMenuItem className="text-destructive" onClick={() => setDeleteOpen(true)}>
            <Trash2 className="size-4 mr-2" /> Delete
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>

      <ConfirmDialog
        open={deleteOpen}
        onOpenChange={setDeleteOpen}
        title="Delete Task"
        description={`Delete "${task.title}"? This cannot be undone.`}
        confirmLabel="Delete"
        variant="destructive"
        loading={deleteMutation.isPending}
        onConfirm={() => {
          deleteMutation.mutate(task.id, {
            onSuccess: () => router.push("/app/tasks"),
          });
        }}
      />
    </>
  );
}

Step 6: Create TemplateSearch component

// src/components/tasks/template-search.tsx
"use client";

import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { Input } from "@/components/ui/input";
import { searchTaskTemplates } from "@/lib/api/lookups";
import { Search } from "lucide-react";
import type { TaskTemplateResponse } from "@/lib/api/lookups";

interface TemplateSearchProps {
  onSelect: (template: TaskTemplateResponse) => void;
  onTitleChange: (title: string) => void;
}

export function TemplateSearch({ onSelect, onTitleChange }: TemplateSearchProps) {
  const [query, setQuery] = useState("");
  const [showResults, setShowResults] = useState(false);

  const { data: results } = useQuery({
    queryKey: ["task-templates", "search", query],
    queryFn: () => searchTaskTemplates(query),
    enabled: query.length >= 2,
  });

  useEffect(() => {
    onTitleChange(query);
  }, [query, onTitleChange]);

  return (
    <div className="relative">
      <div className="relative">
        <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
        <Input
          placeholder="Type a task name or search templates..."
          value={query}
          onChange={(e) => {
            setQuery(e.target.value);
            setShowResults(true);
          }}
          onFocus={() => setShowResults(true)}
          onBlur={() => setTimeout(() => setShowResults(false), 200)}
          className="pl-9"
        />
      </div>
      {showResults && results && results.length > 0 && (
        <div className="absolute top-full left-0 right-0 z-50 mt-1 rounded-md border bg-popover p-1 shadow-md max-h-60 overflow-y-auto">
          {results.map((template) => (
            <button
              key={template.id}
              type="button"
              className="w-full text-left rounded px-3 py-2 text-sm hover:bg-accent"
              onMouseDown={(e) => {
                e.preventDefault();
                setQuery(template.title);
                setShowResults(false);
                onSelect(template);
              }}
            >
              <p className="font-medium">{template.title}</p>
              {template.description && (
                <p className="text-xs text-muted-foreground truncate">{template.description}</p>
              )}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Step 7: Create TaskForm component

// src/components/tasks/task-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 { CurrencyInput } from "@/components/shared/currency-input";
import { TemplateSearch } from "./template-search";
import { useResidences } from "@/lib/hooks/use-residences";
import { useTaskCategories, useTaskPriorities, useTaskFrequencies } from "@/lib/hooks/use-lookups";
import { useContractors } from "@/lib/hooks/use-contractors";
import type { TaskResponse } from "@/lib/api/tasks";

const taskSchema = z.object({
  title: z.string().min(1, "Title is required"),
  residence_id: z.number({ required_error: "Residence is required" }).min(1, "Residence is required"),
  description: z.string().optional(),
  category_id: z.number().optional(),
  priority_id: z.number().optional(),
  frequency_id: z.number().optional(),
  due_date: z.string().optional(),
  estimated_cost: z.number().optional(),
  contractor_id: z.number().optional(),
});

type TaskFormData = z.infer<typeof taskSchema>;

interface TaskFormProps {
  task?: TaskResponse;
  defaultResidenceId?: number;
  onSubmit: (data: TaskFormData) => void;
  loading?: boolean;
}

export function TaskForm({ task, defaultResidenceId, onSubmit, loading }: TaskFormProps) {
  const { data: residences } = useResidences();
  const { data: categories } = useTaskCategories();
  const { data: priorities } = useTaskPriorities();
  const { data: frequencies } = useTaskFrequencies();
  const { data: contractors } = useContractors();

  const residenceItems = (residences ?? []).map((r) => ({
    id: r.residence.id,
    name: r.residence.name,
  }));

  const contractorItems = (contractors ?? []).map((c) => ({
    id: c.id,
    name: c.name,
  }));

  const {
    register,
    handleSubmit,
    setValue,
    watch,
    formState: { errors },
  } = useForm<TaskFormData>({
    resolver: zodResolver(taskSchema),
    defaultValues: task
      ? {
          title: task.title,
          residence_id: task.residence_id,
          description: task.description || undefined,
          category_id: task.category_id ?? undefined,
          priority_id: task.priority_id ?? undefined,
          frequency_id: task.frequency_id ?? undefined,
          due_date: task.due_date ?? undefined,
          estimated_cost: task.estimated_cost ?? undefined,
          contractor_id: task.contractor_id ?? undefined,
        }
      : { residence_id: defaultResidenceId },
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-2xl">
      <FormField label="Title" htmlFor="title" error={errors.title?.message} required>
        {task ? (
          <Input id="title" {...register("title")} />
        ) : (
          <TemplateSearch
            onTitleChange={(title) => setValue("title", title)}
            onSelect={(template) => {
              setValue("title", template.title);
              if (template.category_id) setValue("category_id", template.category_id);
              if (template.priority_id) setValue("priority_id", template.priority_id);
              if (template.frequency_id) setValue("frequency_id", template.frequency_id);
              if (template.estimated_cost) setValue("estimated_cost", template.estimated_cost);
            }}
          />
        )}
      </FormField>

      <FormField label="Residence" htmlFor="residence_id" error={errors.residence_id?.message} required>
        <LookupSelect
          items={residenceItems}
          value={watch("residence_id")}
          onValueChange={(v) => setValue("residence_id", v!)}
          placeholder="Select residence..."
        />
      </FormField>

      <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
        <FormField label="Category" htmlFor="category_id">
          <LookupSelect items={categories} value={watch("category_id")} onValueChange={(v) => setValue("category_id", v)} placeholder="Select..." />
        </FormField>
        <FormField label="Priority" htmlFor="priority_id">
          <LookupSelect items={priorities} value={watch("priority_id")} onValueChange={(v) => setValue("priority_id", v)} placeholder="Select..." />
        </FormField>
        <FormField label="Frequency" htmlFor="frequency_id">
          <LookupSelect items={frequencies} value={watch("frequency_id")} onValueChange={(v) => setValue("frequency_id", v)} placeholder="Select..." />
        </FormField>
      </div>

      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
        <FormField label="Due Date" htmlFor="due_date">
          <Input id="due_date" type="date" {...register("due_date")} />
        </FormField>
        <FormField label="Estimated Cost" htmlFor="estimated_cost">
          <CurrencyInput value={watch("estimated_cost")} onChange={(v) => setValue("estimated_cost", v)} />
        </FormField>
      </div>

      <FormField label="Contractor" htmlFor="contractor_id">
        <LookupSelect items={contractorItems} value={watch("contractor_id")} onValueChange={(v) => setValue("contractor_id", v)} placeholder="Select contractor..." />
      </FormField>

      <FormField label="Notes" htmlFor="description">
        <Textarea id="description" rows={3} {...register("description")} />
      </FormField>

      <Button type="submit" disabled={loading}>
        {loading ? "Saving..." : task ? "Update Task" : "Create Task"}
      </Button>
    </form>
  );
}

Step 8: Create TaskCompletionForm component

// src/components/tasks/task-completion-form.tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useState } from "react";
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 { CurrencyInput } from "@/components/shared/currency-input";
import { StarRating } from "@/components/shared/star-rating";
import { FileUpload } from "@/components/shared/file-upload";

const completionSchema = z.object({
  completed_at: z.string().optional(),
  actual_cost: z.number().optional(),
  notes: z.string().optional(),
  rating: z.number().min(0).max(5).optional(),
});

type CompletionFormData = z.infer<typeof completionSchema>;

interface TaskCompletionFormProps {
  taskId: number;
  onSubmit: (data: CompletionFormData, images: File[]) => void;
  loading?: boolean;
}

export function TaskCompletionForm({ taskId, onSubmit, loading }: TaskCompletionFormProps) {
  const [images, setImages] = useState<File[]>([]);

  const {
    register,
    handleSubmit,
    setValue,
    watch,
    formState: { errors },
  } = useForm<CompletionFormData>({
    resolver: zodResolver(completionSchema),
    defaultValues: {
      completed_at: new Date().toISOString().slice(0, 16),
      rating: 0,
    },
  });

  return (
    <form onSubmit={handleSubmit((data) => onSubmit(data, images))} className="space-y-6 max-w-2xl">
      <FormField label="Completed At" htmlFor="completed_at">
        <Input id="completed_at" type="datetime-local" {...register("completed_at")} />
      </FormField>

      <FormField label="Actual Cost" htmlFor="actual_cost">
        <CurrencyInput value={watch("actual_cost")} onChange={(v) => setValue("actual_cost", v)} />
      </FormField>

      <FormField label="Rating" htmlFor="rating">
        <StarRating value={watch("rating") ?? 0} onChange={(v) => setValue("rating", v)} />
      </FormField>

      <FormField label="Notes" htmlFor="notes">
        <Textarea id="notes" rows={3} {...register("notes")} placeholder="How did it go?" />
      </FormField>

      <FormField label="Photos" htmlFor="photos">
        <FileUpload
          accept="image/*"
          multiple
          files={images}
          onChange={setImages}
          label="Upload completion photos"
        />
      </FormField>

      <Button type="submit" disabled={loading}>
        {loading ? "Completing..." : "Complete Task"}
      </Button>
    </form>
  );
}

Step 9: Build Tasks Kanban page

// src/app/app/tasks/page.tsx  (overwrite stub)
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTasks } from "@/lib/hooks/use-tasks";
import { useResidences } from "@/lib/hooks/use-residences";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { KanbanBoard } from "@/components/tasks/kanban-board";
import { LookupSelect } from "@/components/shared/lookup-select";
import { ClipboardList } from "lucide-react";
import { useTasksByResidence } from "@/lib/hooks/use-tasks";

export default function TasksPage() {
  const router = useRouter();
  const [residenceFilter, setResidenceFilter] = useState<number>();
  const { data: residences } = useResidences();

  const allTasks = useTasks();
  const filteredTasks = useTasksByResidence(residenceFilter!);

  const activeQuery = residenceFilter ? filteredTasks : allTasks;
  const { data, isLoading, error, refetch } = activeQuery;

  const residenceItems = (residences ?? []).map((r) => ({
    id: r.residence.id,
    name: r.residence.name,
  }));

  const isEmpty = data?.columns.every((c) => c.tasks.length === 0);

  return (
    <div className="space-y-6">
      <PageHeader
        title="Tasks"
        description="Manage and track your home tasks"
        actionLabel="New Task"
        onAction={() => router.push("/app/tasks/new")}
      >
        <div className="w-48">
          <LookupSelect
            items={residenceItems}
            value={residenceFilter}
            onValueChange={setResidenceFilter}
            placeholder="All residences"
          />
        </div>
      </PageHeader>

      {isLoading && <LoadingSkeleton variant="kanban" count={6} />}
      {error && <ErrorBanner message={error.message} onRetry={() => refetch()} />}

      {data && isEmpty && (
        <EmptyState
          icon={ClipboardList}
          title="No tasks yet"
          description="Create your first task to start tracking maintenance."
          actionLabel="New Task"
          onAction={() => router.push("/app/tasks/new")}
        />
      )}

      {data && !isEmpty && <KanbanBoard data={data} />}
    </div>
  );
}

Step 10: Build Create, Detail, Edit, Complete task pages

Create these 4 pages following the same patterns as Residences (Steps 6-8 in Task 2). Each page:

  • src/app/app/tasks/new/page.tsxuseCreateTask() + TaskForm
  • src/app/app/tasks/[id]/page.tsxuseTask(id) + detail layout + TaskActionsMenu + completion history
  • src/app/app/tasks/[id]/edit/page.tsxuseUpdateTask(id) + TaskForm pre-filled
  • src/app/app/tasks/[id]/complete/page.tsxuseCreateCompletion() + TaskCompletionForm

Key code for task detail page (src/app/app/tasks/[id]/page.tsx):

"use client";

import { use } from "react";
import { useTask, useTaskCompletions } from "@/lib/hooks/use-tasks";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { TaskActionsMenu } from "@/components/tasks/task-actions-menu";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { StarRating } from "@/components/shared/star-rating";
import { Calendar, DollarSign, RotateCcw, User } from "lucide-react";

export default function TaskDetailPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = use(params);
  const taskId = Number(id);
  const { data: task, isLoading, error, refetch } = useTask(taskId);
  const { data: completions } = useTaskCompletions(taskId);

  if (isLoading) return <LoadingSkeleton variant="detail" />;
  if (error) return <ErrorBanner message={error.message} onRetry={() => refetch()} />;
  if (!task) return null;

  return (
    <div className="space-y-6">
      <PageHeader title={task.title}>
        <TaskActionsMenu task={task} />
      </PageHeader>

      <p className="text-muted-foreground">{task.residence_name}</p>

      <div className="flex flex-wrap gap-2">
        {task.priority && <Badge variant="outline">{task.priority.icon} {task.priority.name}</Badge>}
        {task.category && <Badge variant="secondary">{task.category.icon} {task.category.name}</Badge>}
        {task.frequency && <Badge variant="secondary"><RotateCcw className="size-3 mr-1" />{task.frequency.name}</Badge>}
        {task.in_progress && <Badge className="bg-green-500">In Progress</Badge>}
        {task.is_cancelled && <Badge variant="destructive">Cancelled</Badge>}
        {task.is_archived && <Badge variant="secondary">Archived</Badge>}
      </div>

      <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
        {task.due_date && (
          <div className="flex items-center gap-2">
            <Calendar className="size-4 text-muted-foreground" />
            <div><span className="text-muted-foreground">Due</span><p className="font-medium">{new Date(task.due_date).toLocaleDateString()}</p></div>
          </div>
        )}
        {task.estimated_cost != null && (
          <div className="flex items-center gap-2">
            <DollarSign className="size-4 text-muted-foreground" />
            <div><span className="text-muted-foreground">Est. Cost</span><p className="font-medium">${task.estimated_cost}</p></div>
          </div>
        )}
        {task.contractor && (
          <div className="flex items-center gap-2">
            <User className="size-4 text-muted-foreground" />
            <div><span className="text-muted-foreground">Contractor</span><p className="font-medium">{task.contractor.name}</p></div>
          </div>
        )}
      </div>

      {task.description && (
        <Card>
          <CardHeader><CardTitle className="text-sm">Notes</CardTitle></CardHeader>
          <CardContent><p className="text-sm whitespace-pre-wrap">{task.description}</p></CardContent>
        </Card>
      )}

      {completions && completions.length > 0 && (
        <Card>
          <CardHeader><CardTitle className="text-sm">Completion History ({completions.length})</CardTitle></CardHeader>
          <CardContent className="space-y-4">
            {completions.map((c) => (
              <div key={c.id} className="border-b last:border-0 pb-3 last:pb-0">
                <div className="flex items-center justify-between">
                  <p className="text-sm font-medium">{new Date(c.completed_at).toLocaleDateString()}</p>
                  {c.rating != null && c.rating > 0 && <StarRating value={c.rating} readonly size="sm" />}
                </div>
                {c.notes && <p className="text-sm text-muted-foreground mt-1">{c.notes}</p>}
                {c.actual_cost != null && <p className="text-xs text-muted-foreground">Cost: ${c.actual_cost}</p>}
              </div>
            ))}
          </CardContent>
        </Card>
      )}
    </div>
  );
}

Step 11: Verify build

cd honeyDueAPI-Web && npm run build

Step 12: Commit

git add src/app/app/tasks/ src/components/tasks/ src/lib/hooks/use-tasks.ts
git commit -m "feat: add Tasks CRUD with kanban board, completion form, and template search"

Task 4: Contractors CRUD (Parallel Agent 3)

Depends on: Task 1 (shared foundation)

Files:

  • Create: src/app/app/contractors/page.tsx (overwrite stub)
  • Create: src/app/app/contractors/new/page.tsx
  • Create: src/app/app/contractors/[id]/page.tsx
  • Create: src/app/app/contractors/[id]/edit/page.tsx
  • Create: src/components/contractors/contractor-card.tsx
  • Create: src/components/contractors/contractor-form.tsx
  • Create: src/components/contractors/contractor-filters.tsx
  • Modify: src/lib/hooks/use-contractors.ts (add mutation hooks)

Step 1: Add mutation hooks to use-contractors.ts

Add to src/lib/hooks/use-contractors.ts:

// Add these imports and hooks after the existing query hooks:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CreateContractorRequest, UpdateContractorRequest } from '@/lib/api/contractors';

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'] }); },
  });
}

Step 2-8: Build ContractorCard, ContractorForm, ContractorFilters, and 4 pages

Follow the same patterns as Residences (Task 2). Key differences:

  • ContractorCard: Shows name, company, specialties as badges, phone/email quick actions, favorite star toggle
  • ContractorFilters: Search input + specialty dropdown + favorite-only toggle
  • ContractorForm: Fields: name (required), company, phone, email, residence (required), specialty (multi-select from lookups), notes, is_favorite toggle
  • List page: Client-side filtering via search + specialty + favorite
  • Detail page: Contact info with tel: and mailto: links, linked tasks list, favorite toggle, edit/delete

Step 9: Verify build

cd honeyDueAPI-Web && npm run build

Step 10: Commit

git add src/app/app/contractors/ src/components/contractors/ src/lib/hooks/use-contractors.ts
git commit -m "feat: add Contractors CRUD — list with search/filter, detail, create, edit, delete, favorites"

Task 5: Documents CRUD (Parallel Agent 4)

Depends on: Task 1 (shared foundation)

Files:

  • Create: src/app/app/documents/page.tsx (overwrite stub)
  • Create: src/app/app/documents/new/page.tsx
  • Create: src/app/app/documents/[id]/page.tsx
  • Create: src/app/app/documents/[id]/edit/page.tsx
  • Create: src/components/documents/document-card.tsx
  • Create: src/components/documents/document-form.tsx
  • Create: src/components/documents/warranty-status.tsx
  • Create: src/components/documents/image-gallery.tsx
  • Modify: src/lib/hooks/use-documents.ts (add mutation hooks)

Step 1: Add mutation hooks to use-documents.ts

Add to src/lib/hooks/use-documents.ts:

// Add these imports and hooks after existing query hooks:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CreateDocumentRequest, UpdateDocumentRequest } from '@/lib/api/documents';

export function useCreateDocument() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ data, file }: { data: CreateDocumentRequest; file?: File }) =>
      file
        ? documentsApi.createDocumentWithFile(data, file)
        : 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'] }); },
  });
}

Step 2-8: Build DocumentCard, DocumentForm, WarrantyStatus, ImageGallery, and 4 pages

Key differences from other domains:

  • List page: Tabbed view using shadcn Tabs — "Documents" tab and "Warranties" tab. Warranties tab uses useWarranties(), documents tab uses useDocuments()
  • WarrantyStatus component: Shows active/expired/expiring-soon badge based on expiry_date. Color-coded: green (active), red (expired), yellow (expiring within 30 days)
  • DocumentForm: Conditional warranty fields (purchase_date, expiry_date, purchase_price, vendor, serial_number, model_number) shown when document_type === 'warranty' or is_warranty toggle is on
  • ImageGallery: Grid of document images with lightbox click-to-enlarge. Uses image_url from DocumentImageResponse[]
  • File upload: Uses FileUpload shared component. On create, passes file to createDocumentWithFile
  • Detail page: File preview/download button, image gallery, warranty info card if applicable

Step 9: Verify build

cd honeyDueAPI-Web && npm run build

Step 10: Commit

git add src/app/app/documents/ src/components/documents/ src/lib/hooks/use-documents.ts
git commit -m "feat: add Documents CRUD — tabbed list, warranty tracking, file upload, image gallery"

Task 6: Integration + Verification

Depends on: Tasks 2, 3, 4, 5 (all domain CRUDs)

Files:

  • Modify: src/app/app/page.tsx (home dashboard)

Step 1: Build Home Dashboard

Replace the stub at src/app/app/page.tsx with a dashboard that shows:

  • Residence count + overdue task count (from useResidences() aggregated)
  • Quick links to each domain
  • Recent activity summary
// src/app/app/page.tsx
"use client";

import Link from "next/link";
import { useResidences } from "@/lib/hooks/use-residences";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Home, ClipboardList, Wrench, FileText, AlertTriangle, Clock } from "lucide-react";

export default function DashboardPage() {
  const { data: residences } = useResidences();

  const totalResidences = residences?.length ?? 0;
  const totalOverdue = residences?.reduce((sum, r) => sum + r.task_summary.overdue, 0) ?? 0;
  const totalDueSoon = residences?.reduce((sum, r) => sum + r.task_summary.due_soon, 0) ?? 0;
  const totalTasks = residences?.reduce((sum, r) => sum + r.task_summary.total, 0) ?? 0;

  const stats = [
    { label: "Residences", value: totalResidences, icon: Home, href: "/app/residences", color: "text-blue-500" },
    { label: "Total Tasks", value: totalTasks, icon: ClipboardList, href: "/app/tasks", color: "text-green-500" },
    { label: "Overdue", value: totalOverdue, icon: AlertTriangle, href: "/app/tasks", color: "text-red-500" },
    { label: "Due Soon", value: totalDueSoon, icon: Clock, href: "/app/tasks", color: "text-yellow-500" },
  ];

  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>

      <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
        {stats.map((stat) => (
          <Link key={stat.label} href={stat.href}>
            <Card className="hover:shadow-md transition-shadow cursor-pointer">
              <CardHeader className="pb-2">
                <CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
                  <stat.icon className={`size-4 ${stat.color}`} />
                  {stat.label}
                </CardTitle>
              </CardHeader>
              <CardContent>
                <p className="text-2xl font-bold">{stat.value}</p>
              </CardContent>
            </Card>
          </Link>
        ))}
      </div>
    </div>
  );
}

Step 2: Full build verification

cd honeyDueAPI-Web && npm run build

Expected: Build succeeds. All routes compile. No type errors.

Step 3: Manual smoke test checklist

Run npm run dev and verify:

  • /app — Dashboard shows stats cards
  • /app/residences — List renders (or empty state)
  • /app/residences/new — Form renders, validation works
  • /app/tasks — Kanban board renders with columns
  • /app/tasks/new — Template search autocompletes, form validates
  • /app/contractors — List with search/filter renders
  • /app/contractors/new — Form renders with specialty select
  • /app/documents — Tabbed view renders (Documents + Warranties tabs)
  • /app/documents/new — Form shows warranty fields conditionally

Step 4: Final commit

git add src/app/app/page.tsx
git commit -m "feat: add home dashboard with summary stats

Completes Phase 2 — Core CRUD for all 4 domains."

Deliverables Checklist

At the end of Phase 2, verify:

  • Residences: list (grid cards), detail, create/edit form, delete with confirmation
  • Tasks: kanban board (drag-and-drop columns), create/edit form, task actions (complete, cancel, archive, in-progress)
  • Task completion: form with file upload (photos), completion history with ratings
  • Task templates: autocomplete search in task title field
  • Contractors: list (search, filter by favorite/specialty), detail (quick actions), create/edit form, delete
  • Documents: tabbed view (warranties/documents), detail, create/edit form (type-specific warranty fields), file upload
  • All forms: validated with Zod schemas
  • Cache invalidation: mutations invalidate relevant queries (including cross-domain: task mutations invalidate residence summaries)
  • Loading/error/empty states: every list and detail page handles all 3 states
  • Build passes: npm run build exits 0