From 264107e3bf82b680b5d2bd2594c2394114a66aa3 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 3 Mar 2026 18:23:44 -0600 Subject: [PATCH] feat: consumer home app layout overhaul + residence kanban board Layout & Navigation: - Tighten max-width to 6xl, adjust padding, add warm gradient overlay - Add icons to desktop nav links, responsive header height, stronger blur - Active pill highlight on mobile nav icons - Fix middleware blocking static assets (logo.png) behind auth Dashboard restructure: - Merge quick actions into hero area as inline pills - Rename "Coming Up" to "Needs Attention", exclude completed tasks - Promote task cards to #2 with richer card design (2-col grid, colored date badges) - Drop "Your Homes" to #3 with accent bars and larger icons Card redesigns: - Residence cards: accent bar, home icon, warm hover shadow - Contractor cards: letter avatar, text contact links, separator - Document cards: type-colored accent bar, restructured footer - Task cards: warm hover shadow - Empty states: larger icon container, gradient bg, rounded CTA Residence detail page: - Add full kanban board with drag-and-drop for task management - Add "Add Task" button pre-filling residence on task form - Replace broken Users stat with Overdue/Completed stats - Compute task summary from kanban columns (always accurate) Data accuracy fixes: - Fix getMyResidences() to fetch kanban data in parallel and compute real per-residence task counts instead of hardcoding zeros - Task form accepts defaultResidenceId prop for pre-filling - New task page reads residence_id from URL, redirects back after create Co-Authored-By: Claude Opus 4.6 --- src/app/app/contractors/page.tsx | 2 +- src/app/app/documents/page.tsx | 4 +- src/app/app/layout.tsx | 3 +- src/app/app/page.tsx | 232 +++++++++--------- src/app/app/residences/[id]/page.tsx | 74 +++++- src/app/app/residences/page.tsx | 2 +- src/app/app/tasks/new/page.tsx | 13 +- src/app/demo/app/layout.tsx | 3 +- .../contractors/contractor-card.tsx | 58 +++-- .../dashboard/template-suggestions.tsx | 48 ++++ src/components/documents/document-card.tsx | 59 +++-- src/components/layout/mobile-nav.tsx | 11 +- src/components/layout/top-bar.tsx | 16 +- src/components/residences/residence-card.tsx | 60 +++-- .../residences/residence-summary.tsx | 19 +- src/components/shared/empty-state.tsx | 8 +- src/components/shared/page-header.tsx | 4 +- src/components/tasks/task-card.tsx | 2 +- src/components/tasks/task-form.tsx | 5 +- src/lib/api/residences.ts | 34 ++- src/lib/hooks/use-task-templates.ts | 19 ++ src/middleware.ts | 2 +- 22 files changed, 445 insertions(+), 233 deletions(-) create mode 100644 src/components/dashboard/template-suggestions.tsx create mode 100644 src/lib/hooks/use-task-templates.ts diff --git a/src/app/app/contractors/page.tsx b/src/app/app/contractors/page.tsx index 181f95c..e42f519 100644 --- a/src/app/app/contractors/page.tsx +++ b/src/app/app/contractors/page.tsx @@ -155,7 +155,7 @@ export default function ContractorsPage() { onAction={contractors.length === 0 ? () => router.push(`${basePath}/contractors/new`) : undefined} /> ) : ( -
+
{filtered.map((c) => ( 0 && ( -
+
{documents.map((doc) => ( ))} @@ -103,7 +103,7 @@ export default function DocumentsPage() { !warrantiesError && Array.isArray(warranties) && warranties.length > 0 && ( -
+
{warranties.map((doc) => ( ))} diff --git a/src/app/app/layout.tsx b/src/app/app/layout.tsx index d5f75b0..520a781 100644 --- a/src/app/app/layout.tsx +++ b/src/app/app/layout.tsx @@ -9,9 +9,10 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { return (
+
-
+
{children}
diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index f9b0e62..b5311a8 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -18,6 +18,7 @@ import { useTasks } from "@/lib/hooks/use-tasks"; import { useAuthStore } from "@/stores/auth"; import { useDataProvider } from "@/lib/demo/data-provider-context"; import { Button } from "@/components/ui/button"; +import { TemplateSuggestions } from "@/components/dashboard/template-suggestions"; import { Skeleton } from "@/components/ui/skeleton"; import type { MyResidenceResponse } from "@/lib/api/residences"; import type { TaskResponse } from "@/lib/api/tasks"; @@ -68,59 +69,62 @@ function HomeCard({ href={`${basePath}/residences/${r.id}`} className="group block" > -
- {/* Status badge */} -
-
- +
+
+
+ {/* Status badge */} +
+
+ +
+ {overdue > 0 ? ( + + + {overdue} overdue + + ) : !isGood && dueSoon > 0 ? ( + + + {dueSoon} coming up + + ) : isGood && total > 0 ? ( + + + All good + + ) : null}
- {overdue > 0 ? ( - - - {overdue} overdue - - ) : !isGood && dueSoon > 0 ? ( - - - {dueSoon} coming up - - ) : isGood && total > 0 ? ( - - - All good - - ) : null} -
- {/* Name and address */} -

- {r.name} -

- {address && ( -

- - {address} -

- )} + {/* Name and address */} +

+ {r.name} +

+ {address && ( +

+ + {address} +

+ )} - {/* Quick stats */} -
- - {total} {total === 1 ? "task" : "tasks"} - - - View home - - + {/* Quick stats */} +
+ + {total} {total === 1 ? "task" : "tasks"} + + + View home + + +
); } -/* ─── Coming Up (task list) ─── */ +/* ─── Needs Attention (task cards) ─── */ -function ComingUp({ +function NeedsAttention({ tasks, basePath, }: { @@ -132,7 +136,7 @@ function ComingUp({ return (
-

Coming Up

+

Needs Attention

-
+
{tasks.map((task) => { const dateLabel = task.next_due_date || task.due_date; - const isOverdue = - dateLabel && new Date(dateLabel) < new Date() && getRelativeDate(dateLabel) === "Overdue"; + const relative = dateLabel ? getRelativeDate(dateLabel) : null; + const isOverdue = relative === "Overdue"; + const isToday = relative === "Today"; return ( -
-
-

- {task.title} -

-

- {task.residence_name} -

-
- {dateLabel && ( - +
- {getRelativeDate(dateLabel)} - - )} + /> +
+

+ {task.title} +

+

+ {task.residence_name} +

+
+ {relative && ( + + {relative} + + )} +
); })} @@ -188,32 +199,6 @@ function ComingUp({ ); } -/* ─── Quick Actions (subtle, not prominent) ─── */ - -function QuickActions({ basePath }: { basePath: string }) { - const actions = [ - { label: "Add task", href: `${basePath}/tasks/new`, icon: CheckSquare }, - { label: "Add pro", href: `${basePath}/contractors/new`, icon: HardHat }, - { label: "Save doc", href: `${basePath}/documents/new`, icon: FileText }, - { label: "Add home", href: `${basePath}/residences/new`, icon: Home }, - ]; - - return ( -
- {actions.map((a) => ( - - - {a.label} - - ))} -
- ); -} - /* ─── Loading State ─── */ function DashboardSkeleton() { @@ -248,12 +233,17 @@ export default function DashboardPage() { const name = user?.first_name || ""; const greeting = `${getTimeGreeting()}${name ? `, ${name}` : ""}`; - // Flatten all tasks from kanban columns, sort by due date, take upcoming ones + // Flatten all tasks from kanban columns (exclude completed/cancelled) const allTasks: TaskResponse[] = kanban?.columns ?.flatMap((col) => col.tasks) ?.filter((t) => !t.is_cancelled && !t.is_archived) ?? []; - const upcomingTasks = allTasks + // Active tasks that need attention — overdue first, then due soonest + const activeTasks = allTasks + .filter((t) => { + const col = kanban?.columns.find((c) => c.tasks.some((ct) => ct.id === t.id)); + return col?.name !== "completed_tasks"; + }) .filter((t) => t.next_due_date || t.due_date || t.in_progress) .sort((a, b) => { const dateA = a.next_due_date || a.due_date || ""; @@ -287,8 +277,8 @@ export default function DashboardPage() { if (homes.length === 0) { return (
-
- +
+

Welcome to Casera{name ? `, ${name}` : ""} @@ -338,10 +328,18 @@ export default function DashboardPage() { ); } + /* ─── Quick action pill data ─── */ + const quickActions = [ + { label: "Add task", href: `${basePath}/tasks/new`, icon: CheckSquare }, + { label: "Add pro", href: `${basePath}/contractors/new`, icon: HardHat }, + { label: "Save doc", href: `${basePath}/documents/new`, icon: FileText }, + { label: "Add home", href: `${basePath}/residences/new`, icon: Home }, + ]; + /* ─── Main dashboard ─── */ return (
- {/* Greeting */} + {/* Hero — Greeting + inline quick actions */}

{greeting} @@ -349,9 +347,24 @@ export default function DashboardPage() { {statusMsg && (

{statusMsg}

)} +
+ {quickActions.map((a) => ( + + + {a.label} + + ))} +

- {/* Your Homes — the main content */} + {/* Needs Attention — overdue and upcoming tasks */} + + + {/* Your Homes — dropped to #3 */}

Your Homes

@@ -364,7 +377,7 @@ export default function DashboardPage() {
-
- {/* Coming Up — clean task list */} - - - {/* Quick actions — subtle pills at the bottom */} - + {/* Template suggestions — at the bottom */} + 0} activeTaskCount={allTasks.length} />
); } diff --git a/src/app/app/residences/[id]/page.tsx b/src/app/app/residences/[id]/page.tsx index 7f876d8..c0f4de1 100644 --- a/src/app/app/residences/[id]/page.tsx +++ b/src/app/app/residences/[id]/page.tsx @@ -2,7 +2,9 @@ import { use, useState } from "react"; import { useRouter } from "next/navigation"; -import { MapPin, Pencil, Share2, Trash2, FileDown } from "lucide-react"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { MapPin, Pencil, Share2, Trash2, FileDown, Plus, ClipboardList, ArrowRight } from "lucide-react"; import { toast } from "sonner"; import { useDataProvider } from "@/lib/demo/data-provider-context"; @@ -13,7 +15,13 @@ import { LoadingSkeleton } from "@/components/shared/loading-skeleton"; import { ErrorBanner } from "@/components/shared/error-banner"; import { ConfirmDialog } from "@/components/shared/confirm-dialog"; import { ResidenceSummary } from "@/components/residences/residence-summary"; -import { useResidence, useResidences, useDeleteResidence } from "@/lib/hooks/use-residences"; +import { useResidence, useDeleteResidence } from "@/lib/hooks/use-residences"; +import { useTasksByResidence } from "@/lib/hooks/use-tasks"; + +const KanbanBoard = dynamic( + () => import("@/components/tasks/kanban-board").then((mod) => ({ default: mod.KanbanBoard })), + { loading: () => } +); interface ResidenceDetailPageProps { params: Promise<{ id: string }>; @@ -26,8 +34,8 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps const { basePath, sharing } = useDataProvider(); const { data: residence, isLoading, error, refetch } = useResidence(id); - const { data: residences } = useResidences(); const deleteResidence = useDeleteResidence(); + const { data: kanbanData, isLoading: tasksLoading } = useTasksByResidence(id); const [deleteOpen, setDeleteOpen] = useState(false); const [reportLoading, setReportLoading] = useState(false); @@ -49,10 +57,15 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps } }; - // Find the task summary from the residences list - const resList = Array.isArray(residences) ? residences : []; - const myResidence = resList.find((r) => r.residence.id === id); - const taskSummary = myResidence?.task_summary; + // Compute task summary directly from kanban columns (always accurate) + const taskSummary = kanbanData + ? { + total: kanbanData.columns.reduce((sum, col) => sum + col.tasks.length, 0), + in_progress: kanbanData.columns.find((c) => c.name === "in_progress_tasks")?.tasks.length ?? 0, + overdue: kanbanData.columns.find((c) => c.name === "overdue_tasks")?.tasks.length ?? 0, + completed: kanbanData.columns.find((c) => c.name === "completed_tasks")?.tasks.length ?? 0, + } + : undefined; if (isLoading) { return ( @@ -152,10 +165,55 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps )} + {/* Tasks Kanban Board */} +
+
+
+

Tasks

+ + View all + + +
+ +
+ + {tasksLoading && } + + {!tasksLoading && kanbanData && kanbanData.columns.every((col) => col.tasks.length === 0) && ( +
+ +

No tasks yet

+

+ Add your first task to start tracking what needs to get done around this home. +

+ +
+ )} + + {!tasksLoading && kanbanData && !kanbanData.columns.every((col) => col.tasks.length === 0) && ( + + )} +
+ {/* Description */} {residence.description && ( diff --git a/src/app/app/residences/page.tsx b/src/app/app/residences/page.tsx index 39e10a0..c3e5ec1 100644 --- a/src/app/app/residences/page.tsx +++ b/src/app/app/residences/page.tsx @@ -45,7 +45,7 @@ export default function ResidencesPage() { )} {!isLoading && !error && Array.isArray(residences) && residences.length > 0 && ( -
+
{residences.map((item) => ( ))} diff --git a/src/app/app/tasks/new/page.tsx b/src/app/app/tasks/new/page.tsx index 0286b0d..5857dd0 100644 --- a/src/app/app/tasks/new/page.tsx +++ b/src/app/app/tasks/new/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { toast } from "sonner"; import { PageHeader } from "@/components/shared/page-header"; import { Card, CardContent } from "@/components/ui/card"; @@ -10,9 +10,13 @@ import { useDataProvider } from "@/lib/demo/data-provider-context"; export default function NewTaskPage() { const router = useRouter(); + const searchParams = useSearchParams(); const { basePath } = useDataProvider(); const createTask = useCreateTask(); + const residenceId = searchParams.get("residence_id"); + const defaultResidenceId = residenceId ? Number(residenceId) : undefined; + return (
@@ -20,11 +24,16 @@ export default function NewTaskPage() { { createTask.mutate(data, { onSuccess: () => { toast.success("Task created"); - router.push(`${basePath}/tasks`); + if (defaultResidenceId) { + router.push(`${basePath}/residences/${defaultResidenceId}`); + } else { + router.push(`${basePath}/tasks`); + } }, onError: () => { toast.error("Failed to create task"); diff --git a/src/app/demo/app/layout.tsx b/src/app/demo/app/layout.tsx index 8e4ff1c..3bfa11a 100644 --- a/src/app/demo/app/layout.tsx +++ b/src/app/demo/app/layout.tsx @@ -10,10 +10,11 @@ export default function DemoAppLayout({ children }: { children: React.ReactNode return (
+
-
+
{children}
diff --git a/src/components/contractors/contractor-card.tsx b/src/components/contractors/contractor-card.tsx index f5471de..6bb84f6 100644 --- a/src/components/contractors/contractor-card.tsx +++ b/src/components/contractors/contractor-card.tsx @@ -5,6 +5,7 @@ import { Phone, Mail, Star } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useDataProvider } from "@/lib/demo/data-provider-context"; + import type { ContractorResponse } from "@/lib/api/contractors"; interface ContractorCardProps { @@ -15,18 +16,23 @@ interface ContractorCardProps { export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardProps) { const { basePath } = useDataProvider(); return ( -
+
-
- - {contractor.name} - - {contractor.company && ( -

{contractor.company}

- )} +
+
+ {contractor.name[0]?.toUpperCase()} +
+
+ + {contractor.name} + + {contractor.company && ( +

{contractor.company}

+ )} +
+ e.stopPropagation()} + className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-primary transition-colors" + > + )} {contractor.email && ( - + e.stopPropagation()} + className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-primary transition-colors" + > + )}
diff --git a/src/components/dashboard/template-suggestions.tsx b/src/components/dashboard/template-suggestions.tsx new file mode 100644 index 0000000..7f69c79 --- /dev/null +++ b/src/components/dashboard/template-suggestions.tsx @@ -0,0 +1,48 @@ +"use client"; + +import Link from "next/link"; +import { Lightbulb, Plus } from "lucide-react"; +import { useTaskTemplatesGrouped } from "@/lib/hooks/use-task-templates"; +import { useDataProvider } from "@/lib/demo/data-provider-context"; + +interface TemplateSuggestionsProps { + /** Only show when user has few active tasks */ + hasResidences: boolean; + activeTaskCount: number; +} + +export function TemplateSuggestions({ hasResidences, activeTaskCount }: TemplateSuggestionsProps) { + const { data, isLoading } = useTaskTemplatesGrouped(); + const { basePath } = useDataProvider(); + + // Only show when the user has residences but few tasks + if (!hasResidences || activeTaskCount > 8 || isLoading) return null; + + // Flatten and pick first 4 templates + const templates = data?.groups + ?.flatMap((g) => g.templates) + ?.slice(0, 4) ?? []; + + if (templates.length === 0) return null; + + return ( +
+
+ +

Suggested for your home

+
+
+ {templates.map((t) => ( + + {t.title} + + + ))} +
+
+ ); +} diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index a43bdc5..9b5257a 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -42,35 +42,48 @@ export function DocumentCard({ document: doc }: DocumentCardProps) { const Icon = getFileIcon(doc.mime_type); const { basePath } = useDataProvider(); + const accentColorMap: Record = { + general: "from-muted-foreground/40 to-muted-foreground/10", + warranty: "from-amber-500/60 to-amber-500/20", + receipt: "from-emerald-500/60 to-emerald-500/20", + contract: "from-blue-500/60 to-blue-500/20", + insurance: "from-purple-500/60 to-purple-500/20", + manual: "from-orange-500/60 to-orange-500/20", + }; + return ( -
-
-