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:
@@ -155,7 +155,7 @@ export default function ContractorsPage() {
|
|||||||
onAction={contractors.length === 0 ? () => router.push(`${basePath}/contractors/new`) : undefined}
|
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) => (
|
{filtered.map((c) => (
|
||||||
<ContractorCard
|
<ContractorCard
|
||||||
key={c.id}
|
key={c.id}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function DocumentsPage() {
|
|||||||
!documentsError &&
|
!documentsError &&
|
||||||
Array.isArray(documents) &&
|
Array.isArray(documents) &&
|
||||||
documents.length > 0 && (
|
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) => (
|
{documents.map((doc) => (
|
||||||
<DocumentCard key={doc.id} document={doc} />
|
<DocumentCard key={doc.id} document={doc} />
|
||||||
))}
|
))}
|
||||||
@@ -103,7 +103,7 @@ export default function DocumentsPage() {
|
|||||||
!warrantiesError &&
|
!warrantiesError &&
|
||||||
Array.isArray(warranties) &&
|
Array.isArray(warranties) &&
|
||||||
warranties.length > 0 && (
|
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) => (
|
{warranties.map((doc) => (
|
||||||
<DocumentCard key={doc.id} document={doc} />
|
<DocumentCard key={doc.id} document={doc} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<DataProviderProvider value={realProvider}>
|
<DataProviderProvider value={realProvider}>
|
||||||
<div className="min-h-screen bg-background">
|
<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 />
|
<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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+121
-111
@@ -18,6 +18,7 @@ import { useTasks } from "@/lib/hooks/use-tasks";
|
|||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { TemplateSuggestions } from "@/components/dashboard/template-suggestions";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import type { MyResidenceResponse } from "@/lib/api/residences";
|
import type { MyResidenceResponse } from "@/lib/api/residences";
|
||||||
import type { TaskResponse } from "@/lib/api/tasks";
|
import type { TaskResponse } from "@/lib/api/tasks";
|
||||||
@@ -68,59 +69,62 @@ function HomeCard({
|
|||||||
href={`${basePath}/residences/${r.id}`}
|
href={`${basePath}/residences/${r.id}`}
|
||||||
className="group block"
|
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">
|
<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">
|
||||||
{/* Status badge */}
|
<div className="h-1 bg-gradient-to-r from-primary/60 to-primary/20" />
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="p-5 sm:p-6">
|
||||||
<div className="size-11 rounded-xl bg-[#FFF3EB] flex items-center justify-center">
|
{/* Status badge */}
|
||||||
<Home className="size-5 text-[#E07A3A]" />
|
<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>
|
</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 */}
|
{/* Name and address */}
|
||||||
<h3 className="font-heading text-lg font-semibold leading-tight group-hover:text-primary transition-colors">
|
<h3 className="font-heading text-lg font-semibold leading-tight group-hover:text-primary transition-colors">
|
||||||
{r.name}
|
{r.name}
|
||||||
</h3>
|
</h3>
|
||||||
{address && (
|
{address && (
|
||||||
<p className="text-sm text-muted-foreground mt-1 flex items-center gap-1.5">
|
<p className="text-sm text-muted-foreground mt-1 flex items-center gap-1.5">
|
||||||
<MapPin className="size-3.5 shrink-0" />
|
<MapPin className="size-3.5 shrink-0" />
|
||||||
<span className="truncate">{address}</span>
|
<span className="truncate">{address}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quick stats */}
|
{/* Quick stats */}
|
||||||
<div className="flex items-center gap-4 mt-4 pt-4 border-t border-border/60">
|
<div className="flex items-center gap-4 mt-4 pt-4 border-t border-border/60">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{total} {total === 1 ? "task" : "tasks"}
|
{total} {total === 1 ? "task" : "tasks"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
View home
|
View home
|
||||||
<ArrowRight className="size-3 transition-transform group-hover:translate-x-0.5" />
|
<ArrowRight className="size-3 transition-transform group-hover:translate-x-0.5" />
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Coming Up (task list) ─── */
|
/* ─── Needs Attention (task cards) ─── */
|
||||||
|
|
||||||
function ComingUp({
|
function NeedsAttention({
|
||||||
tasks,
|
tasks,
|
||||||
basePath,
|
basePath,
|
||||||
}: {
|
}: {
|
||||||
@@ -132,7 +136,7 @@ function ComingUp({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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
|
<Link
|
||||||
href={`${basePath}/tasks`}
|
href={`${basePath}/tasks`}
|
||||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
{tasks.map((task) => {
|
{tasks.map((task) => {
|
||||||
const dateLabel = task.next_due_date || task.due_date;
|
const dateLabel = task.next_due_date || task.due_date;
|
||||||
const isOverdue =
|
const relative = dateLabel ? getRelativeDate(dateLabel) : null;
|
||||||
dateLabel && new Date(dateLabel) < new Date() && getRelativeDate(dateLabel) === "Overdue";
|
const isOverdue = relative === "Overdue";
|
||||||
|
const isToday = relative === "Today";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={task.id}
|
key={task.id}
|
||||||
href={`${basePath}/tasks/${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
|
<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">
|
||||||
className={`size-2 rounded-full shrink-0 ${
|
<div
|
||||||
isOverdue
|
className={`size-2.5 rounded-full shrink-0 ${
|
||||||
? "bg-red-500"
|
isOverdue
|
||||||
: task.in_progress
|
? "bg-red-500"
|
||||||
? "bg-[#0D7C66]"
|
: task.in_progress
|
||||||
: "bg-border"
|
? "bg-primary"
|
||||||
}`}
|
: "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"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
/>
|
||||||
{getRelativeDate(dateLabel)}
|
<div className="flex-1 min-w-0">
|
||||||
</span>
|
<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>
|
</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 ─── */
|
/* ─── Loading State ─── */
|
||||||
|
|
||||||
function DashboardSkeleton() {
|
function DashboardSkeleton() {
|
||||||
@@ -248,12 +233,17 @@ export default function DashboardPage() {
|
|||||||
const name = user?.first_name || "";
|
const name = user?.first_name || "";
|
||||||
const greeting = `${getTimeGreeting()}${name ? `, ${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
|
const allTasks: TaskResponse[] = kanban?.columns
|
||||||
?.flatMap((col) => col.tasks)
|
?.flatMap((col) => col.tasks)
|
||||||
?.filter((t) => !t.is_cancelled && !t.is_archived) ?? [];
|
?.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)
|
.filter((t) => t.next_due_date || t.due_date || t.in_progress)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const dateA = a.next_due_date || a.due_date || "";
|
const dateA = a.next_due_date || a.due_date || "";
|
||||||
@@ -287,8 +277,8 @@ export default function DashboardPage() {
|
|||||||
if (homes.length === 0) {
|
if (homes.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
<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">
|
<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-[#E07A3A]" />
|
<Home className="size-9 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-heading text-3xl font-bold tracking-tight">
|
<h1 className="font-heading text-3xl font-bold tracking-tight">
|
||||||
Welcome to Casera{name ? `, ${name}` : ""}
|
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 ─── */
|
/* ─── Main dashboard ─── */
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{/* Greeting */}
|
{/* Hero — Greeting + inline quick actions */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-heading text-2xl sm:text-3xl font-bold tracking-tight">
|
<h1 className="font-heading text-2xl sm:text-3xl font-bold tracking-tight">
|
||||||
{greeting}
|
{greeting}
|
||||||
@@ -349,9 +347,24 @@ export default function DashboardPage() {
|
|||||||
{statusMsg && (
|
{statusMsg && (
|
||||||
<p className="text-muted-foreground mt-1.5 text-[15px]">{statusMsg}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Your Homes — the main content */}
|
{/* Needs Attention — overdue and upcoming tasks */}
|
||||||
|
<NeedsAttention tasks={activeTasks} basePath={basePath} />
|
||||||
|
|
||||||
|
{/* Your Homes — dropped to #3 */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="font-heading text-lg font-semibold">Your Homes</h2>
|
<h2 className="font-heading text-lg font-semibold">Your Homes</h2>
|
||||||
@@ -364,7 +377,7 @@ export default function DashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`grid gap-4 ${
|
<div className={`grid gap-5 ${
|
||||||
homes.length === 1
|
homes.length === 1
|
||||||
? "grid-cols-1 max-w-lg"
|
? "grid-cols-1 max-w-lg"
|
||||||
: "sm:grid-cols-2 lg:grid-cols-3"
|
: "sm:grid-cols-2 lg:grid-cols-3"
|
||||||
@@ -375,11 +388,8 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Coming Up — clean task list */}
|
{/* Template suggestions — at the bottom */}
|
||||||
<ComingUp tasks={upcomingTasks} basePath={basePath} />
|
<TemplateSuggestions hasResidences={homes.length > 0} activeTaskCount={allTasks.length} />
|
||||||
|
|
||||||
{/* Quick actions — subtle pills at the bottom */}
|
|
||||||
<QuickActions basePath={basePath} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { use, useState } from "react";
|
import { use, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { toast } from "sonner";
|
||||||
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
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 { ErrorBanner } from "@/components/shared/error-banner";
|
||||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||||
import { ResidenceSummary } from "@/components/residences/residence-summary";
|
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 {
|
interface ResidenceDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -26,8 +34,8 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
|
|||||||
const { basePath, sharing } = useDataProvider();
|
const { basePath, sharing } = useDataProvider();
|
||||||
|
|
||||||
const { data: residence, isLoading, error, refetch } = useResidence(id);
|
const { data: residence, isLoading, error, refetch } = useResidence(id);
|
||||||
const { data: residences } = useResidences();
|
|
||||||
const deleteResidence = useDeleteResidence();
|
const deleteResidence = useDeleteResidence();
|
||||||
|
const { data: kanbanData, isLoading: tasksLoading } = useTasksByResidence(id);
|
||||||
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [reportLoading, setReportLoading] = 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
|
// Compute task summary directly from kanban columns (always accurate)
|
||||||
const resList = Array.isArray(residences) ? residences : [];
|
const taskSummary = kanbanData
|
||||||
const myResidence = resList.find((r) => r.residence.id === id);
|
? {
|
||||||
const taskSummary = myResidence?.task_summary;
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -152,10 +165,55 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
|
|||||||
<ResidenceSummary
|
<ResidenceSummary
|
||||||
totalTasks={taskSummary.total}
|
totalTasks={taskSummary.total}
|
||||||
inProgress={taskSummary.in_progress}
|
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 */}
|
{/* Description */}
|
||||||
{residence.description && (
|
{residence.description && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function ResidencesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && Array.isArray(residences) && residences.length > 0 && (
|
{!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) => (
|
{residences.map((item) => (
|
||||||
<ResidenceCard key={item.residence.id} data={item} />
|
<ResidenceCard key={item.residence.id} data={item} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { PageHeader } from "@/components/shared/page-header";
|
import { PageHeader } from "@/components/shared/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -10,9 +10,13 @@ import { useDataProvider } from "@/lib/demo/data-provider-context";
|
|||||||
|
|
||||||
export default function NewTaskPage() {
|
export default function NewTaskPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { basePath } = useDataProvider();
|
const { basePath } = useDataProvider();
|
||||||
const createTask = useCreateTask();
|
const createTask = useCreateTask();
|
||||||
|
|
||||||
|
const residenceId = searchParams.get("residence_id");
|
||||||
|
const defaultResidenceId = residenceId ? Number(residenceId) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 w-full max-w-2xl mx-auto">
|
<div className="space-y-6 w-full max-w-2xl mx-auto">
|
||||||
<PageHeader title="New Task" />
|
<PageHeader title="New Task" />
|
||||||
@@ -20,11 +24,16 @@ export default function NewTaskPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<TaskForm
|
<TaskForm
|
||||||
|
defaultResidenceId={defaultResidenceId}
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
createTask.mutate(data, {
|
createTask.mutate(data, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Task created");
|
toast.success("Task created");
|
||||||
router.push(`${basePath}/tasks`);
|
if (defaultResidenceId) {
|
||||||
|
router.push(`${basePath}/residences/${defaultResidenceId}`);
|
||||||
|
} else {
|
||||||
|
router.push(`${basePath}/tasks`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error("Failed to create task");
|
toast.error("Failed to create task");
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ export default function DemoAppLayout({ children }: { children: React.ReactNode
|
|||||||
return (
|
return (
|
||||||
<DataProviderProvider value={demoProvider}>
|
<DataProviderProvider value={demoProvider}>
|
||||||
<div className="min-h-screen bg-background">
|
<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 />
|
<DemoBanner />
|
||||||
<TopBar />
|
<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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Phone, Mail, Star } from "lucide-react";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
||||||
|
|
||||||
import type { ContractorResponse } from "@/lib/api/contractors";
|
import type { ContractorResponse } from "@/lib/api/contractors";
|
||||||
|
|
||||||
interface ContractorCardProps {
|
interface ContractorCardProps {
|
||||||
@@ -15,18 +16,23 @@ interface ContractorCardProps {
|
|||||||
export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardProps) {
|
export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardProps) {
|
||||||
const { basePath } = useDataProvider();
|
const { basePath } = useDataProvider();
|
||||||
return (
|
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="flex items-start justify-between mb-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||||
<Link
|
<div className="size-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0 text-primary font-bold text-sm">
|
||||||
href={`${basePath}/contractors/${contractor.id}`}
|
{contractor.name[0]?.toUpperCase()}
|
||||||
className="font-heading font-bold text-base leading-tight hover:text-primary transition-colors line-clamp-1"
|
</div>
|
||||||
>
|
<div className="min-w-0">
|
||||||
{contractor.name}
|
<Link
|
||||||
</Link>
|
href={`${basePath}/contractors/${contractor.id}`}
|
||||||
{contractor.company && (
|
className="font-heading font-bold text-base leading-tight hover:text-primary transition-colors line-clamp-1"
|
||||||
<p className="text-sm text-muted-foreground mt-0.5 truncate">{contractor.company}</p>
|
>
|
||||||
)}
|
{contractor.name}
|
||||||
|
</Link>
|
||||||
|
{contractor.company && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5 truncate">{contractor.company}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -60,20 +66,28 @@ export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardP
|
|||||||
</div>
|
</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 && (
|
{contractor.phone && (
|
||||||
<Button variant="outline" size="icon" className="size-8 rounded-lg" asChild>
|
<a
|
||||||
<a href={`tel:${contractor.phone}`} aria-label={`Call ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
|
href={`tel:${contractor.phone}`}
|
||||||
<Phone className="size-3.5" aria-hidden="true" />
|
aria-label={`Call ${contractor.name}`}
|
||||||
</a>
|
onClick={(e) => e.stopPropagation()}
|
||||||
</Button>
|
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 && (
|
{contractor.email && (
|
||||||
<Button variant="outline" size="icon" className="size-8 rounded-lg" asChild>
|
<a
|
||||||
<a href={`mailto:${contractor.email}`} aria-label={`Email ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
|
href={`mailto:${contractor.email}`}
|
||||||
<Mail className="size-3.5" aria-hidden="true" />
|
aria-label={`Email ${contractor.name}`}
|
||||||
</a>
|
onClick={(e) => e.stopPropagation()}
|
||||||
</Button>
|
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>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,35 +42,48 @@ export function DocumentCard({ document: doc }: DocumentCardProps) {
|
|||||||
const Icon = getFileIcon(doc.mime_type);
|
const Icon = getFileIcon(doc.mime_type);
|
||||||
const { basePath } = useDataProvider();
|
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 (
|
return (
|
||||||
<Link href={`${basePath}/documents/${doc.id}`} className="block group">
|
<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="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="flex items-start gap-3 mb-4">
|
<div className={`h-0.5 bg-gradient-to-r ${accentColorMap[doc.document_type] ?? accentColorMap.general}`} />
|
||||||
<div className={`rounded-xl p-2.5 shrink-0 ${typeColors[doc.document_type] ?? typeColors.general}`} aria-hidden="true">
|
<div className="p-5">
|
||||||
<Icon className="size-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>
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="font-heading font-bold text-base leading-tight truncate group-hover:text-primary transition-colors">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 pt-3 mt-4">
|
||||||
{doc.title}
|
<div className="flex items-center gap-2">
|
||||||
</h3>
|
<Badge variant="outline" className="rounded-lg">
|
||||||
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
{typeLabels[doc.document_type] ?? doc.document_type}
|
||||||
{doc.residence_name}
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ export function MobileNav() {
|
|||||||
<nav
|
<nav
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label="Main 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) => {
|
{navItems.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
item.href === basePath
|
item.href === basePath
|
||||||
@@ -37,7 +37,12 @@ export function MobileNav() {
|
|||||||
: 'text-muted-foreground'
|
: '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>
|
<span className="font-medium">{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export function TopBar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 bg-background/80 backdrop-blur-xl border-b border-border/60">
|
<header className="sticky top-0 z-30 bg-card/90 backdrop-blur-2xl border-b border-border/40">
|
||||||
<div className="max-w-7xl mx-auto flex items-center justify-between h-16 px-6">
|
<div className="max-w-6xl mx-auto flex items-center justify-between h-14 sm:h-16 px-4 sm:px-8">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href={basePath} className="flex items-center gap-2.5 shrink-0 group">
|
<Link href={basePath} className="flex items-center gap-2.5 shrink-0 group">
|
||||||
<Image
|
<Image
|
||||||
@@ -60,7 +60,7 @@ export function TopBar() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop nav links */}
|
{/* 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) => {
|
{navItems.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
item.href === basePath
|
item.href === basePath
|
||||||
@@ -73,16 +73,14 @@ export function TopBar() {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? 'text-foreground'
|
? 'bg-primary/10 text-primary'
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<item.icon className="size-4" />
|
||||||
{item.label}
|
{item.label}
|
||||||
{isActive && (
|
|
||||||
<span className="absolute bottom-0 left-3.5 right-3.5 h-[2px] bg-foreground rounded-full" />
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MapPin } from "lucide-react";
|
import { Home, MapPin } from "lucide-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
||||||
@@ -21,33 +21,41 @@ export function ResidenceCard({ data }: ResidenceCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`${basePath}/residences/${residence.id}`} className="block group">
|
<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="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="mb-3">
|
<div className="h-1 bg-gradient-to-r from-primary/60 to-primary/20" />
|
||||||
<h3 className="font-heading font-bold text-base leading-tight group-hover:text-primary transition-colors">
|
<div className="p-5">
|
||||||
{residence.name}
|
<div className="flex items-start gap-3 mb-3">
|
||||||
</h3>
|
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
{address && (
|
<Home className="size-5 text-primary" />
|
||||||
<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>
|
</div>
|
||||||
)}
|
<div className="min-w-0">
|
||||||
</div>
|
<h3 className="font-heading font-bold text-base leading-tight group-hover:text-primary transition-colors">
|
||||||
<div className="flex flex-wrap gap-2">
|
{residence.name}
|
||||||
{task_summary.overdue > 0 && (
|
</h3>
|
||||||
<Badge variant="destructive" className="rounded-lg">
|
{address && (
|
||||||
{task_summary.overdue} overdue
|
<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>
|
</Badge>
|
||||||
)}
|
</div>
|
||||||
{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>
|
</div>
|
||||||
</Link>
|
</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";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
interface ResidenceSummaryProps {
|
interface ResidenceSummaryProps {
|
||||||
totalTasks: number;
|
totalTasks: number;
|
||||||
inProgress: number;
|
inProgress: number;
|
||||||
userCount: number;
|
overdue: number;
|
||||||
|
completed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
|
accent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ icon: Icon, label, value }: StatCardProps) {
|
function StatCard({ icon: Icon, label, value, accent }: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-4">
|
<CardContent className="flex items-center gap-4">
|
||||||
<div className="rounded-full bg-muted p-2.5">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold">{value}</p>
|
<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 (
|
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={ClipboardList} label="Total Tasks" value={totalTasks} />
|
||||||
<StatCard icon={Wrench} label="In Progress" value={inProgress} />
|
<StatCard icon={Wrench} label="In Progress" value={inProgress} accent="text-[#C4856A]" />
|
||||||
<StatCard icon={Users} label="Users" value={userCount} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ interface EmptyStateProps {
|
|||||||
|
|
||||||
export function EmptyState({ icon: Icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
export function EmptyState({ icon: Icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||||
<div className="size-16 rounded-2xl bg-[#FFF3EB] flex items-center justify-center mb-5">
|
<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-7 text-[#E07A3A]" />
|
<Icon className="size-9 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-heading text-lg font-bold">{title}</h3>
|
<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>
|
<p className="text-sm text-muted-foreground mt-2 max-w-sm leading-relaxed">{description}</p>
|
||||||
{actionLabel && onAction && (
|
{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}
|
<Plus className="size-4 mr-2" />{actionLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface PageHeaderProps {
|
|||||||
|
|
||||||
export function PageHeader({ title, description, actionLabel, onAction, children }: PageHeaderProps) {
|
export function PageHeader({ title, description, actionLabel, onAction, children }: PageHeaderProps) {
|
||||||
return (
|
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>
|
<div>
|
||||||
<h1 className="font-heading text-2xl font-bold tracking-tight">{title}</h1>
|
<h1 className="font-heading text-2xl font-bold tracking-tight">{title}</h1>
|
||||||
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>}
|
{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">
|
<div className="flex items-center gap-2">
|
||||||
{children}
|
{children}
|
||||||
{actionLabel && onAction && (
|
{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}
|
<Plus className="size-4 mr-2" />{actionLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function TaskCard({ task, isDragging }: TaskCardProps) {
|
|||||||
<Link href={`${basePath}/tasks/${task.id}`}>
|
<Link href={`${basePath}/tasks/${task.id}`}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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]"
|
isDragging && "shadow-lg ring-2 ring-primary rotate-[1deg] scale-[1.02]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ type TaskFormValues = z.infer<typeof taskSchema>;
|
|||||||
|
|
||||||
interface TaskFormProps {
|
interface TaskFormProps {
|
||||||
task?: TaskResponse;
|
task?: TaskResponse;
|
||||||
|
defaultResidenceId?: number;
|
||||||
onSubmit: (data: TaskFormValues) => void;
|
onSubmit: (data: TaskFormValues) => void;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskForm({ task, onSubmit, isSubmitting }: TaskFormProps) {
|
export function TaskForm({ task, defaultResidenceId, onSubmit, isSubmitting }: TaskFormProps) {
|
||||||
const isEdit = !!task;
|
const isEdit = !!task;
|
||||||
|
|
||||||
const { data: residences } = useResidences();
|
const { data: residences } = useResidences();
|
||||||
@@ -86,7 +87,7 @@ export function TaskForm({ task, onSubmit, isSubmitting }: TaskFormProps) {
|
|||||||
resolver: zodResolver(taskSchema),
|
resolver: zodResolver(taskSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
title: task?.title ?? "",
|
title: task?.title ?? "",
|
||||||
residence_id: task?.residence_id,
|
residence_id: task?.residence_id ?? defaultResidenceId,
|
||||||
description: task?.description ?? "",
|
description: task?.description ?? "",
|
||||||
category_id: task?.category_id,
|
category_id: task?.category_id,
|
||||||
priority_id: task?.priority_id,
|
priority_id: task?.priority_id,
|
||||||
|
|||||||
@@ -159,13 +159,37 @@ export function listResidences(): Promise<ResidenceResponse[]> {
|
|||||||
/** Get the user's residences with task summaries. */
|
/** Get the user's residences with task summaries. */
|
||||||
export async function getMyResidences(): Promise<MyResidenceResponse[]> {
|
export async function getMyResidences(): Promise<MyResidenceResponse[]> {
|
||||||
// Go API returns { "residences": [ResidenceResponse, ...] }
|
// Go API returns { "residences": [ResidenceResponse, ...] }
|
||||||
// Each ResidenceResponse has overdue_count but no task_summary.
|
// Each ResidenceResponse has overdue_count but no full task_summary.
|
||||||
// We transform into MyResidenceResponse shape for compatibility.
|
// We fetch the kanban data in parallel and compute real per-residence counts.
|
||||||
const data = await apiFetch<{ residences: ResidenceResponse[] }>('/residences/my-residences/');
|
const [resData, kanbanData] = await Promise.all([
|
||||||
const residences = data.residences ?? [];
|
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) => ({
|
return residences.map((r) => ({
|
||||||
residence: r,
|
residence: r,
|
||||||
task_summary: {
|
task_summary: countsMap.get(r.id) ?? {
|
||||||
total: 0,
|
total: 0,
|
||||||
overdue: r.overdue_count ?? 0,
|
overdue: r.overdue_count ?? 0,
|
||||||
due_soon: 0,
|
due_soon: 0,
|
||||||
|
|||||||
@@ -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
@@ -9,7 +9,7 @@ export function middleware(request: NextRequest) {
|
|||||||
const publicPaths = ['/', '/login', '/register', '/forgot-password', '/reset-password', '/verify-email', '/demo'];
|
const publicPaths = ['/', '/login', '/register', '/forgot-password', '/reset-password', '/verify-email', '/demo'];
|
||||||
const isPublicPath = publicPaths.some(p => pathname === p || pathname.startsWith(p + '/'));
|
const isPublicPath = publicPaths.some(p => pathname === p || pathname.startsWith(p + '/'));
|
||||||
const isApiPath = pathname.startsWith('/api/');
|
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
|
// Skip middleware for API routes and static files
|
||||||
if (isApiPath || isStaticPath) return NextResponse.next();
|
if (isApiPath || isStaticPath) return NextResponse.next();
|
||||||
|
|||||||
Reference in New Issue
Block a user