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>
+36 -22
View File
@@ -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 (
<div className="group rounded-2xl border border-border bg-card p-5 transition-all duration-200 hover:shadow-md hover:shadow-black/[0.04] hover:-translate-y-0.5">
<div className="group rounded-2xl border border-border bg-card p-5 transition-all duration-200 hover:shadow-[var(--shadow-warm-md)] hover:-translate-y-0.5">
<div className="flex items-start justify-between mb-3">
<div className="min-w-0 flex-1">
<Link
href={`${basePath}/contractors/${contractor.id}`}
className="font-heading font-bold text-base leading-tight hover:text-primary transition-colors line-clamp-1"
>
{contractor.name}
</Link>
{contractor.company && (
<p className="text-sm text-muted-foreground mt-0.5 truncate">{contractor.company}</p>
)}
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="size-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0 text-primary font-bold text-sm">
{contractor.name[0]?.toUpperCase()}
</div>
<div className="min-w-0">
<Link
href={`${basePath}/contractors/${contractor.id}`}
className="font-heading font-bold text-base leading-tight hover:text-primary transition-colors line-clamp-1"
>
{contractor.name}
</Link>
{contractor.company && (
<p className="text-sm text-muted-foreground mt-0.5 truncate">{contractor.company}</p>
)}
</div>
</div>
<Button
variant="ghost"
@@ -60,20 +66,28 @@ export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardP
</div>
)}
<div className="flex items-center gap-2">
<div className="border-t border-border/60 pt-3 mt-3 flex items-center gap-4">
{contractor.phone && (
<Button variant="outline" size="icon" className="size-8 rounded-lg" asChild>
<a href={`tel:${contractor.phone}`} aria-label={`Call ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
<Phone className="size-3.5" aria-hidden="true" />
</a>
</Button>
<a
href={`tel:${contractor.phone}`}
aria-label={`Call ${contractor.name}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
>
<Phone className="size-3" aria-hidden="true" />
Call
</a>
)}
{contractor.email && (
<Button variant="outline" size="icon" className="size-8 rounded-lg" asChild>
<a href={`mailto:${contractor.email}`} aria-label={`Email ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
<Mail className="size-3.5" aria-hidden="true" />
</a>
</Button>
<a
href={`mailto:${contractor.email}`}
aria-label={`Email ${contractor.name}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
>
<Mail className="size-3" aria-hidden="true" />
Email
</a>
)}
</div>
</div>
@@ -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 (
<div>
<div className="flex items-center gap-2 mb-3">
<Lightbulb className="size-4 text-brand-clay" />
<h3 className="text-sm font-medium text-muted-foreground">Suggested for your home</h3>
</div>
<div className="flex flex-wrap gap-2">
{templates.map((t) => (
<Link
key={t.id}
href={`${basePath}/tasks/new?template=${t.id}`}
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-primary/30 hover:shadow-[var(--shadow-warm-sm)] transition-all"
>
{t.title}
<Plus className="size-3 text-primary" />
</Link>
))}
</div>
</div>
);
}
+36 -23
View File
@@ -42,35 +42,48 @@ export function DocumentCard({ document: doc }: DocumentCardProps) {
const Icon = getFileIcon(doc.mime_type);
const { basePath } = useDataProvider();
const accentColorMap: Record<string, string> = {
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 (
<Link href={`${basePath}/documents/${doc.id}`} className="block group">
<div className="rounded-2xl border border-border bg-card p-5 transition-all duration-200 hover:shadow-md hover:shadow-black/[0.04] hover:-translate-y-0.5 hover:border-primary/30">
<div className="flex items-start gap-3 mb-4">
<div className={`rounded-xl p-2.5 shrink-0 ${typeColors[doc.document_type] ?? typeColors.general}`} aria-hidden="true">
<Icon className="size-5" />
<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-primary/30">
<div className={`h-0.5 bg-gradient-to-r ${accentColorMap[doc.document_type] ?? accentColorMap.general}`} />
<div className="p-5">
<div className="flex items-start gap-3 mb-4">
<div className={`rounded-xl p-2.5 shrink-0 ${typeColors[doc.document_type] ?? typeColors.general}`} aria-hidden="true">
<Icon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-heading font-bold text-base leading-tight truncate group-hover:text-primary transition-colors">
{doc.title}
</h3>
<p className="text-sm text-muted-foreground truncate mt-0.5">
{doc.residence_name}
</p>
</div>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-heading font-bold text-base leading-tight truncate group-hover:text-primary transition-colors">
{doc.title}
</h3>
<p className="text-sm text-muted-foreground truncate mt-0.5">
{doc.residence_name}
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 pt-3 mt-4">
<div className="flex items-center gap-2">
<Badge variant="outline" className="rounded-lg">
{typeLabels[doc.document_type] ?? doc.document_type}
</Badge>
{doc.document_type === "warranty" && (
<WarrantyStatus expiry_date={doc.expiry_date} />
)}
</div>
<p className="text-xs text-muted-foreground/70">
{format(new Date(doc.created_at), "MMM d, yyyy")}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="rounded-lg">
{typeLabels[doc.document_type] ?? doc.document_type}
</Badge>
{doc.document_type === "warranty" && (
<WarrantyStatus expiry_date={doc.expiry_date} />
)}
</div>
<p className="text-xs text-muted-foreground/70 mt-3">
{format(new Date(doc.created_at), "MMM d, yyyy")}
</p>
</div>
</Link>
);
+8 -3
View File
@@ -15,9 +15,9 @@ export function MobileNav() {
<nav
role="navigation"
aria-label="Main navigation"
className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-background/80 backdrop-blur-xl border-t border-border/60"
className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-card/90 backdrop-blur-xl border-t border-border/40 shadow-[0_-1px_3px_rgba(45,52,54,0.04)]"
>
<div className="flex items-center justify-around px-1 py-1.5 pb-[calc(0.375rem+env(safe-area-inset-bottom))]">
<div className="max-w-6xl mx-auto flex items-center justify-around px-1 py-1.5 pb-[calc(0.375rem+env(safe-area-inset-bottom))]">
{navItems.map((item) => {
const isActive =
item.href === basePath
@@ -37,7 +37,12 @@ export function MobileNav() {
: 'text-muted-foreground'
)}
>
<item.icon className={cn("size-[22px]", isActive && "text-primary")} aria-hidden="true" />
<div className={cn(
"flex items-center justify-center rounded-full transition-colors",
isActive ? "bg-primary/10 p-1.5" : "p-1.5"
)}>
<item.icon className={cn("size-[22px]", isActive && "text-primary")} aria-hidden="true" />
</div>
<span className="font-medium">{item.label}</span>
</Link>
);
+7 -9
View File
@@ -43,8 +43,8 @@ export function TopBar() {
};
return (
<header className="sticky top-0 z-30 bg-background/80 backdrop-blur-xl border-b border-border/60">
<div className="max-w-7xl mx-auto flex items-center justify-between h-16 px-6">
<header className="sticky top-0 z-30 bg-card/90 backdrop-blur-2xl border-b border-border/40">
<div className="max-w-6xl mx-auto flex items-center justify-between h-14 sm:h-16 px-4 sm:px-8">
{/* Logo */}
<Link href={basePath} className="flex items-center gap-2.5 shrink-0 group">
<Image
@@ -60,7 +60,7 @@ export function TopBar() {
</Link>
{/* Desktop nav links */}
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="Main navigation">
<nav className="hidden md:flex items-center gap-0.5" role="navigation" aria-label="Main navigation">
{navItems.map((item) => {
const isActive =
item.href === basePath
@@ -73,16 +73,14 @@ export function TopBar() {
href={item.href}
aria-current={isActive ? 'page' : undefined}
className={cn(
'relative px-3.5 py-1.5 text-sm font-medium rounded-lg transition-colors',
'inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium rounded-full transition-colors',
isActive
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
)}
>
<item.icon className="size-4" />
{item.label}
{isActive && (
<span className="absolute bottom-0 left-3.5 right-3.5 h-[2px] bg-foreground rounded-full" />
)}
</Link>
);
})}
+34 -26
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { MapPin } from "lucide-react";
import { Home, MapPin } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useDataProvider } from "@/lib/demo/data-provider-context";
@@ -21,33 +21,41 @@ export function ResidenceCard({ data }: ResidenceCardProps) {
return (
<Link href={`${basePath}/residences/${residence.id}`} className="block group">
<div className="rounded-2xl border border-border bg-card p-5 transition-all duration-200 hover:shadow-md hover:shadow-black/[0.04] hover:-translate-y-0.5 hover:border-primary/30">
<div className="mb-3">
<h3 className="font-heading font-bold text-base leading-tight group-hover:text-primary transition-colors">
{residence.name}
</h3>
{address && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-1.5">
<MapPin className="size-3.5 shrink-0" aria-hidden="true" />
<span className="sr-only">Address:</span>
<span className="truncate">{address}</span>
<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-primary/30">
<div className="h-1 bg-gradient-to-r from-primary/60 to-primary/20" />
<div className="p-5">
<div className="flex items-start gap-3 mb-3">
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
<Home className="size-5 text-primary" />
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
{task_summary.overdue > 0 && (
<Badge variant="destructive" className="rounded-lg">
{task_summary.overdue} overdue
<div className="min-w-0">
<h3 className="font-heading font-bold text-base leading-tight group-hover:text-primary transition-colors">
{residence.name}
</h3>
{address && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-1">
<MapPin className="size-3.5 shrink-0" aria-hidden="true" />
<span className="sr-only">Address:</span>
<span className="truncate">{address}</span>
</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{task_summary.overdue > 0 && (
<Badge variant="destructive" className="rounded-lg">
{task_summary.overdue} overdue
</Badge>
)}
{task_summary.due_soon > 0 && (
<Badge variant="secondary" className="rounded-lg">
{task_summary.due_soon} due soon
</Badge>
)}
<Badge variant="outline" className="rounded-lg">
{task_summary.total} {task_summary.total === 1 ? "task" : "tasks"}
</Badge>
)}
{task_summary.due_soon > 0 && (
<Badge variant="secondary" className="rounded-lg">
{task_summary.due_soon} due soon
</Badge>
)}
<Badge variant="outline" className="rounded-lg">
{task_summary.total} {task_summary.total === 1 ? "task" : "tasks"}
</Badge>
</div>
</div>
</div>
</Link>
@@ -1,24 +1,26 @@
import { ClipboardList, Wrench, Users } from "lucide-react";
import { ClipboardList, Wrench, AlertTriangle, CheckCircle2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
interface ResidenceSummaryProps {
totalTasks: number;
inProgress: number;
userCount: number;
overdue: number;
completed: number;
}
interface StatCardProps {
icon: React.ElementType;
label: string;
value: number;
accent?: string;
}
function StatCard({ icon: Icon, label, value }: StatCardProps) {
function StatCard({ icon: Icon, label, value, accent }: StatCardProps) {
return (
<Card>
<CardContent className="flex items-center gap-4">
<div className="rounded-full bg-muted p-2.5">
<Icon className="size-5 text-muted-foreground" />
<Icon className={`size-5 ${accent ?? "text-muted-foreground"}`} />
</div>
<div>
<p className="text-2xl font-bold">{value}</p>
@@ -29,12 +31,13 @@ function StatCard({ icon: Icon, label, value }: StatCardProps) {
);
}
export function ResidenceSummary({ totalTasks, inProgress, userCount }: ResidenceSummaryProps) {
export function ResidenceSummary({ totalTasks, inProgress, overdue, completed }: ResidenceSummaryProps) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard icon={ClipboardList} label="Total Tasks" value={totalTasks} />
<StatCard icon={Wrench} label="In Progress" value={inProgress} />
<StatCard icon={Users} label="Users" value={userCount} />
<StatCard icon={Wrench} label="In Progress" value={inProgress} accent="text-[#C4856A]" />
<StatCard icon={AlertTriangle} label="Overdue" value={overdue} accent={overdue > 0 ? "text-red-500" : "text-muted-foreground"} />
<StatCard icon={CheckCircle2} label="Completed" value={completed} accent="text-primary" />
</div>
);
}
+4 -4
View File
@@ -11,14 +11,14 @@ interface EmptyStateProps {
export function EmptyState({ icon: Icon, title, description, actionLabel, onAction }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="size-16 rounded-2xl bg-[#FFF3EB] flex items-center justify-center mb-5">
<Icon className="size-7 text-[#E07A3A]" />
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="size-20 rounded-3xl bg-gradient-to-br from-primary/10 to-brand-clay/5 flex items-center justify-center mb-5">
<Icon className="size-9 text-primary" />
</div>
<h3 className="font-heading text-lg font-bold">{title}</h3>
<p className="text-sm text-muted-foreground mt-2 max-w-sm leading-relaxed">{description}</p>
{actionLabel && onAction && (
<Button onClick={onAction} className="mt-6 rounded-xl shadow-sm shadow-primary/20">
<Button onClick={onAction} className="mt-6 h-12 px-8 text-base rounded-full shadow-[var(--shadow-warm-sm)]">
<Plus className="size-4 mr-2" />{actionLabel}
</Button>
)}
+2 -2
View File
@@ -12,7 +12,7 @@ interface PageHeaderProps {
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 className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="font-heading text-2xl font-bold tracking-tight">{title}</h1>
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>}
@@ -20,7 +20,7 @@ export function PageHeader({ title, description, actionLabel, onAction, children
<div className="flex items-center gap-2">
{children}
{actionLabel && onAction && (
<Button onClick={onAction} className="rounded-xl shadow-sm">
<Button onClick={onAction} className="h-11 px-6 text-base rounded-xl shadow-[var(--shadow-warm-sm)]">
<Plus className="size-4 mr-2" />{actionLabel}
</Button>
)}
+1 -1
View File
@@ -18,7 +18,7 @@ export function TaskCard({ task, isDragging }: TaskCardProps) {
<Link href={`${basePath}/tasks/${task.id}`}>
<div
className={cn(
"rounded-xl border bg-card p-3.5 space-y-2 transition-all duration-200 hover:shadow-md hover:shadow-black/[0.04] cursor-grab",
"rounded-xl border bg-card p-3.5 space-y-2 transition-all duration-200 hover:shadow-[var(--shadow-warm-sm)] cursor-grab",
isDragging && "shadow-lg ring-2 ring-primary rotate-[1deg] scale-[1.02]"
)}
>
+3 -2
View File
@@ -36,11 +36,12 @@ type TaskFormValues = z.infer<typeof taskSchema>;
interface TaskFormProps {
task?: TaskResponse;
defaultResidenceId?: number;
onSubmit: (data: TaskFormValues) => void;
isSubmitting?: boolean;
}
export function TaskForm({ task, onSubmit, isSubmitting }: TaskFormProps) {
export function TaskForm({ task, defaultResidenceId, onSubmit, isSubmitting }: TaskFormProps) {
const isEdit = !!task;
const { data: residences } = useResidences();
@@ -86,7 +87,7 @@ export function TaskForm({ task, onSubmit, isSubmitting }: TaskFormProps) {
resolver: zodResolver(taskSchema),
defaultValues: {
title: task?.title ?? "",
residence_id: task?.residence_id,
residence_id: task?.residence_id ?? defaultResidenceId,
description: task?.description ?? "",
category_id: task?.category_id,
priority_id: task?.priority_id,
+29 -5
View File
@@ -159,13 +159,37 @@ export function listResidences(): Promise<ResidenceResponse[]> {
/** Get the user's residences with task summaries. */
export async function getMyResidences(): Promise<MyResidenceResponse[]> {
// Go API returns { "residences": [ResidenceResponse, ...] }
// Each ResidenceResponse has overdue_count but no task_summary.
// We transform into MyResidenceResponse shape for compatibility.
const data = await apiFetch<{ residences: ResidenceResponse[] }>('/residences/my-residences/');
const residences = data.residences ?? [];
// Each ResidenceResponse has overdue_count but no full task_summary.
// We fetch the kanban data in parallel and compute real per-residence counts.
const [resData, kanbanData] = await Promise.all([
apiFetch<{ residences: ResidenceResponse[] }>('/residences/my-residences/'),
apiFetch<{ columns: { name: string; tasks: { residence_id: number }[] }[] }>('/tasks/').catch(() => null),
]);
const residences = resData.residences ?? [];
// Build per-residence task counts from kanban columns
const countsMap = new Map<number, TaskSummary>();
if (kanbanData?.columns) {
for (const col of kanbanData.columns) {
for (const task of col.tasks) {
let c = countsMap.get(task.residence_id);
if (!c) {
c = { total: 0, overdue: 0, due_soon: 0, in_progress: 0, completed: 0 };
countsMap.set(task.residence_id, c);
}
c.total++;
if (col.name === 'overdue_tasks') c.overdue++;
else if (col.name === 'due_soon_tasks') c.due_soon++;
else if (col.name === 'in_progress_tasks') c.in_progress++;
else if (col.name === 'completed_tasks') c.completed++;
}
}
}
return residences.map((r) => ({
residence: r,
task_summary: {
task_summary: countsMap.get(r.id) ?? {
total: 0,
overdue: r.overdue_count ?? 0,
due_soon: 0,
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
getTaskTemplatesGrouped,
type TaskTemplatesGroupedResponse,
} from "@/lib/api/lookups";
/**
* Fetches task templates grouped by category.
* 30-minute staleTime since templates rarely change.
*/
export function useTaskTemplatesGrouped() {
return useQuery<TaskTemplatesGroupedResponse>({
queryKey: ["task-templates", "grouped"],
queryFn: () => getTaskTemplatesGrouped(),
staleTime: 30 * 60 * 1000, // 30 minutes
});
}
+1 -1
View File
@@ -9,7 +9,7 @@ export function middleware(request: NextRequest) {
const publicPaths = ['/', '/login', '/register', '/forgot-password', '/reset-password', '/verify-email', '/demo'];
const isPublicPath = publicPaths.some(p => pathname === p || pathname.startsWith(p + '/'));
const isApiPath = pathname.startsWith('/api/');
const isStaticPath = pathname.startsWith('/_next/') || pathname.startsWith('/favicon');
const isStaticPath = pathname.startsWith('/_next/') || pathname.startsWith('/favicon') || pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|css|js)$/);
// Skip middleware for API routes and static files
if (isApiPath || isStaticPath) return NextResponse.next();