e2172c20f2
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>
2489 lines
79 KiB
Markdown
2489 lines
79 KiB
Markdown
# 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 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
|
|
|
|
```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 (
|
|
<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
|
|
|
|
```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 (
|
|
<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
|
|
|
|
```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 (
|
|
<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
|
|
|
|
```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 (
|
|
<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
|
|
|
|
```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 (
|
|
<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
|
|
|
|
```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 (
|
|
<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
|
|
|
|
```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 (
|
|
<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
|
|
|
|
```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<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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```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 (
|
|
<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
|
|
|
|
```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 honeyDueAPI-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 (
|
|
<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
|
|
|
|
```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<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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```bash
|
|
cd honeyDueAPI-Web && npm run build
|
|
```
|
|
|
|
Expected: Build succeeds. All 4 residence routes render.
|
|
|
|
### Step 10: Commit
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```typescript
|
|
"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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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.tsx` — `useCreateTask()` + `TaskForm`
|
|
- `src/app/app/tasks/[id]/page.tsx` — `useTask(id)` + detail layout + `TaskActionsMenu` + completion history
|
|
- `src/app/app/tasks/[id]/edit/page.tsx` — `useUpdateTask(id)` + `TaskForm` pre-filled
|
|
- `src/app/app/tasks/[id]/complete/page.tsx` — `useCreateCompletion()` + `TaskCompletionForm`
|
|
|
|
**Key code for task detail page (src/app/app/tasks/[id]/page.tsx):**
|
|
|
|
```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
|
|
|
|
```bash
|
|
cd honeyDueAPI-Web && npm run build
|
|
```
|
|
|
|
### Step 12: Commit
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```bash
|
|
cd honeyDueAPI-Web && npm run build
|
|
```
|
|
|
|
### Step 10: Commit
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```bash
|
|
cd honeyDueAPI-Web && npm run build
|
|
```
|
|
|
|
### Step 10: Commit
|
|
|
|
```bash
|
|
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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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
|