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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-03 18:23:44 -06:00
parent db89ddb861
commit 264107e3bf
22 changed files with 445 additions and 233 deletions
+1 -1
View File
@@ -155,7 +155,7 @@ export default function ContractorsPage() {
onAction={contractors.length === 0 ? () => router.push(`${basePath}/contractors/new`) : undefined}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-5">
{filtered.map((c) => (
<ContractorCard
key={c.id}
+2 -2
View File
@@ -70,7 +70,7 @@ export default function DocumentsPage() {
!documentsError &&
Array.isArray(documents) &&
documents.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{documents.map((doc) => (
<DocumentCard key={doc.id} document={doc} />
))}
@@ -103,7 +103,7 @@ export default function DocumentsPage() {
!warrantiesError &&
Array.isArray(warranties) &&
warranties.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{warranties.map((doc) => (
<DocumentCard key={doc.id} document={doc} />
))}
+2 -1
View File
@@ -9,9 +9,10 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<DataProviderProvider value={realProvider}>
<div className="min-h-screen bg-background">
<div className="pointer-events-none fixed inset-0 bg-gradient-to-br from-primary/[0.02] via-transparent to-brand-clay/[0.02]" />
<TopBar />
<main className="max-w-7xl mx-auto px-6 py-8 lg:py-12 pb-28 md:pb-12">
<main className="max-w-6xl mx-auto px-4 sm:px-8 py-6 lg:py-10 pb-28 md:pb-12">
{children}
</main>
+121 -111
View File
@@ -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"
>
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6 transition-all duration-200 hover:shadow-lg hover:shadow-black/[0.04] hover:-translate-y-0.5 hover:border-border/80">
{/* Status badge */}
<div className="flex items-center justify-between mb-4">
<div className="size-11 rounded-xl bg-[#FFF3EB] flex items-center justify-center">
<Home className="size-5 text-[#E07A3A]" />
<div className="rounded-2xl border border-border bg-card overflow-hidden transition-all duration-200 hover:shadow-[var(--shadow-warm-md)] hover:-translate-y-0.5 hover:border-border/80">
<div className="h-1 bg-gradient-to-r from-primary/60 to-primary/20" />
<div className="p-5 sm:p-6">
{/* Status badge */}
<div className="flex items-center justify-between mb-4">
<div className="size-12 rounded-xl bg-primary/10 flex items-center justify-center">
<Home className="size-5 text-primary" />
</div>
{overdue > 0 ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-600 bg-red-50 dark:text-red-400 dark:bg-red-500/10 px-2.5 py-1 rounded-full">
<CircleAlert className="size-3" />
{overdue} overdue
</span>
) : !isGood && dueSoon > 0 ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-600 bg-amber-50 dark:text-amber-400 dark:bg-amber-500/10 px-2.5 py-1 rounded-full">
<CalendarClock className="size-3" />
{dueSoon} coming up
</span>
) : isGood && total > 0 ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-primary bg-primary/10 px-2.5 py-1 rounded-full">
<Sparkles className="size-3" />
All good
</span>
) : null}
</div>
{overdue > 0 ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-600 bg-red-50 dark:text-red-400 dark:bg-red-500/10 px-2.5 py-1 rounded-full">
<CircleAlert className="size-3" />
{overdue} overdue
</span>
) : !isGood && dueSoon > 0 ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-600 bg-amber-50 dark:text-amber-400 dark:bg-amber-500/10 px-2.5 py-1 rounded-full">
<CalendarClock className="size-3" />
{dueSoon} coming up
</span>
) : isGood && total > 0 ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-[#0D7C66] bg-emerald-50 dark:text-emerald-400 dark:bg-emerald-500/10 px-2.5 py-1 rounded-full">
<Sparkles className="size-3" />
All good
</span>
) : null}
</div>
{/* Name and address */}
<h3 className="font-heading text-lg font-semibold leading-tight group-hover:text-primary transition-colors">
{r.name}
</h3>
{address && (
<p className="text-sm text-muted-foreground mt-1 flex items-center gap-1.5">
<MapPin className="size-3.5 shrink-0" />
<span className="truncate">{address}</span>
</p>
)}
{/* Name and address */}
<h3 className="font-heading text-lg font-semibold leading-tight group-hover:text-primary transition-colors">
{r.name}
</h3>
{address && (
<p className="text-sm text-muted-foreground mt-1 flex items-center gap-1.5">
<MapPin className="size-3.5 shrink-0" />
<span className="truncate">{address}</span>
</p>
)}
{/* Quick stats */}
<div className="flex items-center gap-4 mt-4 pt-4 border-t border-border/60">
<span className="text-xs text-muted-foreground">
{total} {total === 1 ? "task" : "tasks"}
</span>
<span className="text-xs text-muted-foreground flex items-center gap-1">
View home
<ArrowRight className="size-3 transition-transform group-hover:translate-x-0.5" />
</span>
{/* Quick stats */}
<div className="flex items-center gap-4 mt-4 pt-4 border-t border-border/60">
<span className="text-xs text-muted-foreground">
{total} {total === 1 ? "task" : "tasks"}
</span>
<span className="text-xs text-muted-foreground flex items-center gap-1">
View home
<ArrowRight className="size-3 transition-transform group-hover:translate-x-0.5" />
</span>
</div>
</div>
</div>
</Link>
);
}
/* ─── Coming Up (task list) ─── */
/* ─── Needs Attention (task cards) ─── */
function ComingUp({
function NeedsAttention({
tasks,
basePath,
}: {
@@ -132,7 +136,7 @@ function ComingUp({
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="font-heading text-lg font-semibold">Coming Up</h2>
<h2 className="font-heading text-lg font-semibold">Needs Attention</h2>
<Link
href={`${basePath}/tasks`}
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
@@ -142,44 +146,51 @@ function ComingUp({
</Link>
</div>
<div className="space-y-1">
<div className="grid sm:grid-cols-2 gap-3">
{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 (
<Link
key={task.id}
href={`${basePath}/tasks/${task.id}`}
className="flex items-center gap-3 rounded-xl px-3 py-3 -mx-3 hover:bg-accent/50 transition-colors group"
className="group"
>
<div
className={`size-2 rounded-full shrink-0 ${
isOverdue
? "bg-red-500"
: task.in_progress
? "bg-[#0D7C66]"
: "bg-border"
}`}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-snug truncate group-hover:text-primary transition-colors">
{task.title}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{task.residence_name}
</p>
</div>
{dateLabel && (
<span
className={`text-xs font-medium shrink-0 ${
isOverdue ? "text-red-500" : "text-muted-foreground"
<div className="flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-all duration-200 hover:shadow-[var(--shadow-warm-sm)] hover:-translate-y-0.5">
<div
className={`size-2.5 rounded-full shrink-0 ${
isOverdue
? "bg-red-500"
: task.in_progress
? "bg-primary"
: "bg-border"
}`}
>
{getRelativeDate(dateLabel)}
</span>
)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold leading-snug truncate group-hover:text-primary transition-colors">
{task.title}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{task.residence_name}
</p>
</div>
{relative && (
<span
className={`text-xs font-medium shrink-0 px-2.5 py-1 rounded-full ${
isOverdue
? "bg-red-50 text-red-600 dark:bg-red-500/10 dark:text-red-400"
: isToday
? "bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400"
: "bg-muted text-muted-foreground"
}`}
>
{relative}
</span>
)}
</div>
</Link>
);
})}
@@ -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 (
<div className="flex flex-wrap gap-2">
{actions.map((a) => (
<Link
key={a.href}
href={a.href}
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3.5 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
>
<a.icon className="size-3.5" />
{a.label}
</Link>
))}
</div>
);
}
/* ─── 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 (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
<div className="size-20 rounded-3xl bg-gradient-to-br from-[#FFF3EB] to-[#ECFDF5] flex items-center justify-center mb-6">
<Home className="size-9 text-[#E07A3A]" />
<div className="size-20 rounded-3xl bg-gradient-to-br from-brand-clay-light to-brand-sage-light flex items-center justify-center mb-6">
<Home className="size-9 text-primary" />
</div>
<h1 className="font-heading text-3xl font-bold tracking-tight">
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 (
<div className="space-y-10">
{/* Greeting */}
{/* Hero — Greeting + inline quick actions */}
<div>
<h1 className="font-heading text-2xl sm:text-3xl font-bold tracking-tight">
{greeting}
@@ -349,9 +347,24 @@ export default function DashboardPage() {
{statusMsg && (
<p className="text-muted-foreground mt-1.5 text-[15px]">{statusMsg}</p>
)}
<div className="flex flex-wrap gap-2 mt-4">
{quickActions.map((a) => (
<Link
key={a.href}
href={a.href}
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-4 py-2.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-primary/30 hover:shadow-[var(--shadow-warm-sm)] transition-all"
>
<a.icon className="size-3.5" />
{a.label}
</Link>
))}
</div>
</div>
{/* Your Homes — the main content */}
{/* Needs Attention — overdue and upcoming tasks */}
<NeedsAttention tasks={activeTasks} basePath={basePath} />
{/* Your Homes — dropped to #3 */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="font-heading text-lg font-semibold">Your Homes</h2>
@@ -364,7 +377,7 @@ export default function DashboardPage() {
</Link>
</div>
<div className={`grid gap-4 ${
<div className={`grid gap-5 ${
homes.length === 1
? "grid-cols-1 max-w-lg"
: "sm:grid-cols-2 lg:grid-cols-3"
@@ -375,11 +388,8 @@ export default function DashboardPage() {
</div>
</div>
{/* Coming Up — clean task list */}
<ComingUp tasks={upcomingTasks} basePath={basePath} />
{/* Quick actions — subtle pills at the bottom */}
<QuickActions basePath={basePath} />
{/* Template suggestions — at the bottom */}
<TemplateSuggestions hasResidences={homes.length > 0} activeTaskCount={allTasks.length} />
</div>
);
}
+66 -8
View File
@@ -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: () => <LoadingSkeleton variant="kanban" /> }
);
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
<ResidenceSummary
totalTasks={taskSummary.total}
inProgress={taskSummary.in_progress}
userCount={residence.user_count}
overdue={taskSummary.overdue}
completed={taskSummary.completed}
/>
)}
{/* Tasks Kanban Board */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h2 className="font-heading text-lg font-semibold">Tasks</h2>
<Link
href={`${basePath}/tasks?residence_id=${id}`}
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
View all
<ArrowRight className="size-3.5" />
</Link>
</div>
<Button asChild size="sm" className="rounded-xl shadow-[var(--shadow-warm-sm)]">
<Link href={`${basePath}/tasks/new?residence_id=${id}`}>
<Plus className="size-4 mr-1.5" />
Add Task
</Link>
</Button>
</div>
{tasksLoading && <LoadingSkeleton variant="kanban" />}
{!tasksLoading && kanbanData && kanbanData.columns.every((col) => col.tasks.length === 0) && (
<div className="flex flex-col items-center justify-center py-16 text-center rounded-2xl border border-dashed border-border/60 bg-muted/20">
<ClipboardList className="size-8 text-muted-foreground/50 mb-3" />
<p className="text-sm font-medium text-muted-foreground">No tasks yet</p>
<p className="text-xs text-muted-foreground/70 mt-1 max-w-xs">
Add your first task to start tracking what needs to get done around this home.
</p>
<Button asChild size="sm" className="mt-4 rounded-full">
<Link href={`${basePath}/tasks/new?residence_id=${id}`}>
<Plus className="size-4 mr-1.5" />
Add Task
</Link>
</Button>
</div>
)}
{!tasksLoading && kanbanData && !kanbanData.columns.every((col) => col.tasks.length === 0) && (
<KanbanBoard data={kanbanData} />
)}
</div>
{/* Description */}
{residence.description && (
<Card>
+1 -1
View File
@@ -45,7 +45,7 @@ export default function ResidencesPage() {
)}
{!isLoading && !error && Array.isArray(residences) && residences.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{residences.map((item) => (
<ResidenceCard key={item.residence.id} data={item} />
))}
+11 -2
View File
@@ -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 (
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Task" />
@@ -20,11 +24,16 @@ export default function NewTaskPage() {
<Card>
<CardContent>
<TaskForm
defaultResidenceId={defaultResidenceId}
onSubmit={(data) => {
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");
+2 -1
View File
@@ -10,10 +10,11 @@ export default function DemoAppLayout({ children }: { children: React.ReactNode
return (
<DataProviderProvider value={demoProvider}>
<div className="min-h-screen bg-background">
<div className="pointer-events-none fixed inset-0 bg-gradient-to-br from-primary/[0.02] via-transparent to-brand-clay/[0.02]" />
<DemoBanner />
<TopBar />
<main className="max-w-7xl mx-auto px-6 py-8 lg:py-12 pb-28 md:pb-12">
<main className="max-w-6xl mx-auto px-4 sm:px-8 py-6 lg:py-10 pb-28 md:pb-12">
{children}
</main>