feat: complete Phase 3 — advanced features for Casera web app

Adds sharing (residence share codes, join, user management, .casera file
export/import), subscription status with feature comparison, notification
preferences with bell icon, profile settings (edit info, change password,
theme picker, delete account), onboarding wizard with create/join paths,
enhanced dashboard with stats cards, Recharts completion chart, recent
activity feed, and task report PDF download.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-03 09:31:29 -06:00
commit 5a50d77515
183 changed files with 34450 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
import {
forgotPasswordSchema,
type ForgotPasswordFormData,
} from "@/lib/validations/auth";
import * as authApi from "@/lib/api/auth";
import { ApiError } from "@/lib/api/client";
export default function ForgotPasswordPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ForgotPasswordFormData>({
resolver: zodResolver(forgotPasswordSchema),
});
async function onSubmit(data: ForgotPasswordFormData) {
setIsLoading(true);
setError(null);
try {
await authApi.forgotPassword({ email: data.email });
router.push(
`/reset-password?email=${encodeURIComponent(data.email)}`
);
} catch (err) {
const message =
err instanceof ApiError
? err.message
: "Failed to send reset code. Please try again.";
setError(message);
} finally {
setIsLoading(false);
}
}
return (
<AuthFormWrapper
title="Forgot password?"
subtitle="Enter your email to receive a reset code"
footer={
<p>
<Link href="/login" className="text-primary hover:underline">
Back to login
</Link>
</p>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
autoComplete="email"
aria-invalid={!!errors.email}
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Send reset code
</Button>
</form>
</AuthFormWrapper>
);
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
{children}
</div>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
import { PasswordInput } from "@/components/forms/password-input";
import { loginSchema, type LoginFormData } from "@/lib/validations/auth";
import { useAuthStore } from "@/stores/auth";
export default function LoginPage() {
const { login, isLoading, error, clearError } = useAuthStore();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
async function onSubmit(data: LoginFormData) {
clearError();
await login({ username: data.username, password: data.password });
}
return (
<AuthFormWrapper
title="Welcome back"
subtitle="Sign in to your account"
footer={
<p>
Don&apos;t have an account?{" "}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</p>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username or email</Label>
<Input
id="username"
placeholder="you@example.com"
autoComplete="username"
aria-invalid={!!errors.username}
{...register("username")}
/>
{errors.username && (
<p className="text-sm text-destructive">
{errors.username.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/forgot-password"
className="text-xs text-muted-foreground hover:text-primary"
>
Forgot password?
</Link>
</div>
<PasswordInput
id="password"
autoComplete="current-password"
aria-invalid={!!errors.password}
{...register("password")}
/>
{errors.password && (
<p className="text-sm text-destructive">
{errors.password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Sign in
</Button>
</form>
</AuthFormWrapper>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
import { PasswordInput } from "@/components/forms/password-input";
import { registerSchema, type RegisterFormData } from "@/lib/validations/auth";
import { useAuthStore } from "@/stores/auth";
export default function RegisterPage() {
const router = useRouter();
const { register: registerUser, isLoading, error, clearError } = useAuthStore();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
async function onSubmit(data: RegisterFormData) {
clearError();
try {
await registerUser({
first_name: data.first_name,
last_name: data.last_name,
username: data.username,
email: data.email,
password: data.password,
});
router.push(
`/verify-email?email=${encodeURIComponent(data.email)}`
);
} catch {
// Error is already set in the store
}
}
return (
<AuthFormWrapper
title="Create account"
subtitle="Get started with Casera"
footer={
<p>
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="first_name">First name</Label>
<Input
id="first_name"
autoComplete="given-name"
aria-invalid={!!errors.first_name}
{...register("first_name")}
/>
{errors.first_name && (
<p className="text-sm text-destructive">
{errors.first_name.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="last_name">Last name</Label>
<Input
id="last_name"
autoComplete="family-name"
aria-invalid={!!errors.last_name}
{...register("last_name")}
/>
{errors.last_name && (
<p className="text-sm text-destructive">
{errors.last_name.message}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
autoComplete="username"
aria-invalid={!!errors.username}
{...register("username")}
/>
{errors.username && (
<p className="text-sm text-destructive">
{errors.username.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
autoComplete="email"
aria-invalid={!!errors.email}
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
autoComplete="new-password"
aria-invalid={!!errors.password}
{...register("password")}
/>
{errors.password && (
<p className="text-sm text-destructive">
{errors.password.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm_password">Confirm password</Label>
<PasswordInput
id="confirm_password"
autoComplete="new-password"
aria-invalid={!!errors.confirm_password}
{...register("confirm_password")}
/>
{errors.confirm_password && (
<p className="text-sm text-destructive">
{errors.confirm_password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Create account
</Button>
</form>
</AuthFormWrapper>
);
}
+208
View File
@@ -0,0 +1,208 @@
"use client";
import { Suspense, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
import { PasswordInput } from "@/components/forms/password-input";
import { CodeInput } from "@/components/forms/code-input";
import { resetPasswordSchema, type ResetPasswordFormData } from "@/lib/validations/auth";
import * as authApi from "@/lib/api/auth";
import { ApiError } from "@/lib/api/client";
type Step = "code" | "password";
function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const email = searchParams.get("email") ?? "";
const [step, setStep] = useState<Step>("code");
const [code, setCode] = useState("");
const [resetToken, setResetToken] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ResetPasswordFormData>({
resolver: zodResolver(resetPasswordSchema),
values: {
email,
code,
new_password: "",
confirm_password: "",
},
});
// Step 1: Verify the 6-digit code
async function handleVerifyCode(submittedCode: string) {
if (submittedCode.length !== 6 || isLoading) return;
setIsLoading(true);
setError(null);
try {
const result = await authApi.verifyResetCode({
email,
code: submittedCode,
});
setResetToken(result.reset_token);
setStep("password");
} catch (err) {
const message =
err instanceof ApiError
? err.message
: "Invalid code. Please try again.";
setError(message);
} finally {
setIsLoading(false);
}
}
function handleCodeChange(newCode: string) {
setCode(newCode);
if (newCode.length === 6) {
handleVerifyCode(newCode);
}
}
// Step 2: Reset password with the token
async function onSubmitPassword(data: ResetPasswordFormData) {
setIsLoading(true);
setError(null);
try {
await authApi.resetPassword({
reset_token: resetToken,
new_password: data.new_password,
});
router.push("/login");
} catch (err) {
const message =
err instanceof ApiError
? err.message
: "Failed to reset password. Please try again.";
setError(message);
} finally {
setIsLoading(false);
}
}
if (step === "code") {
return (
<AuthFormWrapper
title="Enter reset code"
subtitle={
email
? `Enter the 6-digit code sent to ${email}`
: "Enter the 6-digit code sent to your email"
}
footer={
<p>
<Link href="/login" className="text-primary hover:underline">
Back to login
</Link>
</p>
}
>
<div className="flex flex-col gap-6">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<CodeInput
value={code}
onChange={handleCodeChange}
disabled={isLoading}
/>
<Button
type="button"
className="w-full"
disabled={code.length !== 6 || isLoading}
onClick={() => handleVerifyCode(code)}
>
{isLoading && <Loader2 className="animate-spin" />}
Verify code
</Button>
</div>
</AuthFormWrapper>
);
}
return (
<AuthFormWrapper
title="Set new password"
subtitle="Enter your new password below"
footer={
<p>
<Link href="/login" className="text-primary hover:underline">
Back to login
</Link>
</p>
}
>
<form
onSubmit={handleSubmit(onSubmitPassword)}
className="flex flex-col gap-4"
>
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="new_password">New password</Label>
<PasswordInput
id="new_password"
autoComplete="new-password"
aria-invalid={!!errors.new_password}
{...register("new_password")}
/>
{errors.new_password && (
<p className="text-sm text-destructive">
{errors.new_password.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm_password">Confirm password</Label>
<PasswordInput
id="confirm_password"
autoComplete="new-password"
aria-invalid={!!errors.confirm_password}
{...register("confirm_password")}
/>
{errors.confirm_password && (
<p className="text-sm text-destructive">
{errors.confirm_password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Reset password
</Button>
</form>
</AuthFormWrapper>
);
}
export default function ResetPasswordPage() {
return (
<Suspense>
<ResetPasswordForm />
</Suspense>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { Suspense } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
import { CodeInput } from "@/components/forms/code-input";
import * as authApi from "@/lib/api/auth";
import { ApiError } from "@/lib/api/client";
const RESEND_COOLDOWN_SECONDS = 60;
function VerifyEmailForm() {
const router = useRouter();
const searchParams = useSearchParams();
const email = searchParams.get("email") ?? "";
const [code, setCode] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isResending, setIsResending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cooldown, setCooldown] = useState(0);
// Cooldown timer for resend button
useEffect(() => {
if (cooldown <= 0) return;
const timer = setInterval(() => {
setCooldown((c) => c - 1);
}, 1000);
return () => clearInterval(timer);
}, [cooldown]);
const handleSubmit = useCallback(
async (submittedCode: string) => {
if (submittedCode.length !== 6 || isSubmitting) return;
setError(null);
setIsSubmitting(true);
try {
await authApi.verifyEmail({ code: submittedCode });
router.push("/login");
} catch (err) {
const message =
err instanceof ApiError
? err.message
: "Verification failed. Please try again.";
setError(message);
} finally {
setIsSubmitting(false);
}
},
[isSubmitting, router]
);
function handleCodeChange(newCode: string) {
setCode(newCode);
// Auto-submit when all 6 digits are entered
if (newCode.length === 6) {
handleSubmit(newCode);
}
}
async function handleResend() {
setIsResending(true);
setError(null);
try {
await authApi.resendVerification();
setCooldown(RESEND_COOLDOWN_SECONDS);
} catch (err) {
const message =
err instanceof ApiError
? err.message
: "Failed to resend code. Please try again.";
setError(message);
} finally {
setIsResending(false);
}
}
return (
<AuthFormWrapper
title="Verify your email"
subtitle={
email
? `Enter the 6-digit code sent to ${email}`
: "Enter the 6-digit code sent to your email"
}
footer={
<p>
<Link href="/login" className="text-primary hover:underline">
Back to login
</Link>
</p>
}
>
<div className="flex flex-col gap-6">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<CodeInput
value={code}
onChange={handleCodeChange}
disabled={isSubmitting}
/>
<Button
type="button"
className="w-full"
disabled={code.length !== 6 || isSubmitting}
onClick={() => handleSubmit(code)}
>
{isSubmitting && <Loader2 className="animate-spin" />}
Verify email
</Button>
<div className="text-center">
<Button
type="button"
variant="ghost"
size="sm"
disabled={isResending || cooldown > 0}
onClick={handleResend}
>
{isResending && <Loader2 className="animate-spin" />}
{cooldown > 0
? `Resend code (${cooldown}s)`
: "Resend code"}
</Button>
</div>
</div>
</AuthFormWrapper>
);
}
export default function VerifyEmailPage() {
return (
<Suspense>
<VerifyEmailForm />
</Suspense>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
// ---------------------------------------------------------------------------
// POST /api/auth/login
// ---------------------------------------------------------------------------
// Special route handler for login. On success, sets the auth token in an
// httpOnly cookie so it is never exposed to client-side JavaScript.
// ---------------------------------------------------------------------------
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const upstream = await fetch(`${API_BASE_URL}/auth/login/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone':
request.headers.get('x-timezone') ||
Intl.DateTimeFormat().resolvedOptions().timeZone,
},
cache: 'no-store',
body: JSON.stringify(body),
});
const data = await upstream.json().catch(() => null);
if (!upstream.ok) {
return NextResponse.json(
data || { error: 'Login failed' },
{ status: upstream.status },
);
}
// Extract token from Go API response
// The Go API returns { token: "...", user: { ... } }
const token: string | undefined = data?.token;
if (!token) {
return NextResponse.json(
{ error: 'No token in response' },
{ status: 500 },
);
}
// Set httpOnly cookie
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: COOKIE_MAX_AGE,
});
// Return the full response (including user data) to the client,
// but strip the raw token since it is now in the cookie.
const { token: _stripped, ...safeData } = data;
return NextResponse.json(safeData, { status: 200 });
} catch (error) {
console.error('[auth/login] Error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
// ---------------------------------------------------------------------------
// POST /api/auth/logout
// ---------------------------------------------------------------------------
// Clears the httpOnly auth cookie and optionally invalidates the token on
// the Go API side.
// ---------------------------------------------------------------------------
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
export async function POST(request: NextRequest) {
try {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
// Best-effort: tell the Go API to invalidate the token
if (token) {
try {
await fetch(`${API_BASE_URL}/auth/logout/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Token ${token}`,
},
cache: 'no-store',
});
} catch {
// Don't block logout if the upstream call fails
}
}
// Delete the cookie
cookieStore.delete(COOKIE_NAME);
return NextResponse.json({ message: 'Logged out successfully' });
} catch (error) {
console.error('[auth/logout] Error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
// ---------------------------------------------------------------------------
// GET /api/auth/me
// ---------------------------------------------------------------------------
// Returns the current authenticated user. Reads the token from the httpOnly
// cookie and proxies to the Go API.
// ---------------------------------------------------------------------------
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
export async function GET(_request: NextRequest) {
try {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 },
);
}
const upstream = await fetch(`${API_BASE_URL}/auth/me/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Token ${token}`,
},
cache: 'no-store',
});
const data = await upstream.json().catch(() => null);
if (!upstream.ok) {
// If the token is invalid/expired, clear the cookie
if (upstream.status === 401) {
cookieStore.delete(COOKIE_NAME);
}
return NextResponse.json(
data || { error: 'Failed to fetch user' },
{ status: upstream.status },
);
}
return NextResponse.json(data);
} catch (error) {
console.error('[auth/me] Error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}
+188
View File
@@ -0,0 +1,188 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
// ---------------------------------------------------------------------------
// Catch-all proxy route handler
// ---------------------------------------------------------------------------
// Every authenticated client-side API call goes through this proxy.
// It reads the `casera-token` httpOnly cookie and forwards the request to the
// Go API with an Authorization header.
// ---------------------------------------------------------------------------
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
/**
* Build the target URL from the catch-all path segments.
* e.g. /api/proxy/tasks/123/ -> https://mycrib.treytartt.com/api/tasks/123/
*/
function buildTargetUrl(request: NextRequest, pathSegments: string[]): string {
const path = `/${pathSegments.join('/')}`;
// Ensure trailing slash (Go API requires it)
const normalizedPath = path.endsWith('/') ? path : `${path}/`;
// Forward query string if present
const search = request.nextUrl.search;
return `${API_BASE_URL}${normalizedPath}${search}`;
}
/**
* Build headers to forward to Go API.
* Strips hop-by-hop headers and adds Authorization from cookie.
*/
async function buildHeaders(request: NextRequest): Promise<Headers> {
const headers = new Headers();
// Forward select request headers
const forwardHeaders = [
'content-type',
'accept',
'x-timezone',
'x-requested-with',
'if-none-match',
];
for (const name of forwardHeaders) {
const value = request.headers.get(name);
if (value) {
headers.set(name, value);
}
}
// Attach auth token from httpOnly cookie
const cookieStore = await cookies();
const token = cookieStore.get('casera-token')?.value;
if (token) {
headers.set('Authorization', `Token ${token}`);
}
return headers;
}
/**
* Proxy a request to the Go API and return the response.
*/
async function proxyRequest(
request: NextRequest,
pathSegments: string[],
): Promise<NextResponse> {
const targetUrl = buildTargetUrl(request, pathSegments);
const headers = await buildHeaders(request);
// Build fetch options
const fetchOptions: RequestInit = {
method: request.method,
headers,
// Do not let Next.js cache proxy requests
cache: 'no-store',
};
// Forward request body for methods that typically have one
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('multipart/form-data')) {
// Stream the raw body for multipart uploads
fetchOptions.body = await request.arrayBuffer();
} else if (contentType.includes('application/json')) {
fetchOptions.body = await request.text();
} else {
// Fallback: forward raw body
fetchOptions.body = await request.arrayBuffer();
}
}
try {
const upstream = await fetch(targetUrl, fetchOptions);
// Build response headers to forward back to the client
const responseHeaders = new Headers();
const passHeaders = [
'content-type',
'etag',
'cache-control',
'content-disposition',
];
for (const name of passHeaders) {
const value = upstream.headers.get(name);
if (value) {
responseHeaders.set(name, value);
}
}
// Handle 304 Not Modified (no body)
if (upstream.status === 304) {
return new NextResponse(null, {
status: 304,
headers: responseHeaders,
});
}
// Handle 204 No Content
if (upstream.status === 204) {
return new NextResponse(null, {
status: 204,
headers: responseHeaders,
});
}
// Stream the upstream body back
const body = await upstream.arrayBuffer();
return new NextResponse(body, {
status: upstream.status,
headers: responseHeaders,
});
} catch (error) {
console.error('[proxy] Upstream request failed:', error);
return NextResponse.json(
{ error: 'Failed to reach API server' },
{ status: 502 },
);
}
}
// ---------------------------------------------------------------------------
// HTTP method handlers
// ---------------------------------------------------------------------------
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params;
return proxyRequest(request, path);
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params;
return proxyRequest(request, path);
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params;
return proxyRequest(request, path);
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params;
return proxyRequest(request, path);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params;
return proxyRequest(request, path);
}
@@ -0,0 +1,57 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ContractorForm } from "@/components/contractors/contractor-form";
import { useContractor, useUpdateContractor } from "@/lib/hooks/use-contractors";
import type { ContractorFormValues } from "@/components/contractors/contractor-form";
export default function EditContractorPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: idParam } = use(params);
const id = Number(idParam);
const router = useRouter();
const { data: contractor, isLoading, isError, error, refetch } = useContractor(id);
const updateContractor = useUpdateContractor(id);
function handleSubmit(data: ContractorFormValues) {
updateContractor.mutate(data, {
onSuccess: () => {
router.push(`/app/contractors/${id}`);
},
});
}
if (isLoading) {
return <LoadingSkeleton variant="detail" />;
}
if (isError) {
return (
<ErrorBanner
message={error instanceof Error ? error.message : "Failed to load contractor."}
onRetry={() => refetch()}
/>
);
}
if (!contractor) return null;
return (
<div className="space-y-6 max-w-2xl">
<PageHeader title={`Edit ${contractor.name}`} />
<ContractorForm
contractor={contractor}
onSubmit={handleSubmit}
loading={updateContractor.isPending}
/>
</div>
);
}
+252
View File
@@ -0,0 +1,252 @@
"use client";
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Phone, Mail, Globe, Star, Pencil, Trash2, FileDown } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { StarRating } from "@/components/shared/star-rating";
import { downloadCaseraFile } from "@/components/sharing/casera-file-handler";
import {
useContractor,
useContractorTasks,
useDeleteContractor,
useToggleFavorite,
} from "@/lib/hooks/use-contractors";
export default function ContractorDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: idParam } = use(params);
const id = Number(idParam);
const router = useRouter();
const { data: contractor, isLoading, isError, error, refetch } = useContractor(id);
const { data: tasks } = useContractorTasks(id);
const deleteContractor = useDeleteContractor();
const toggleFavorite = useToggleFavorite();
const [deleteOpen, setDeleteOpen] = useState(false);
function handleDelete() {
deleteContractor.mutate(id, {
onSuccess: () => {
router.push("/app/contractors");
},
});
}
if (isLoading) {
return <LoadingSkeleton variant="detail" />;
}
if (isError) {
return (
<ErrorBanner
message={error instanceof Error ? error.message : "Failed to load contractor."}
onRetry={() => refetch()}
/>
);
}
if (!contractor) return null;
return (
<div className="space-y-6 max-w-3xl">
<PageHeader title={contractor.name} description={contractor.company || undefined}>
<Button
variant="ghost"
size="icon"
onClick={() => toggleFavorite.mutate(contractor.id)}
>
<Star
className={
contractor.is_favorite
? "size-5 fill-yellow-400 text-yellow-400"
: "size-5 text-muted-foreground"
}
/>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const exportData = {
type: "casera_contractor_share",
version: 1,
contractor: {
name: contractor.name,
company: contractor.company,
phone: contractor.phone,
email: contractor.email,
website: contractor.website,
notes: contractor.notes,
street_address: contractor.street_address,
city: contractor.city,
state_province: contractor.state_province,
postal_code: contractor.postal_code,
specialty_ids: contractor.specialties.map((s) => s.id),
rating: contractor.rating,
},
exported_at: new Date().toISOString(),
};
const safeName = contractor.name.replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase();
downloadCaseraFile(exportData, `${safeName}-contractor`);
}}
>
<FileDown className="size-4 mr-2" />
Share
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/app/contractors/${contractor.id}/edit`}>
<Pencil className="size-4 mr-2" />
Edit
</Link>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-4 mr-2" />
Delete
</Button>
</PageHeader>
{/* Contact info */}
<Card>
<CardHeader>
<CardTitle>Contact Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{contractor.phone && (
<div className="flex items-center gap-3">
<Phone className="size-4 text-muted-foreground" />
<a href={`tel:${contractor.phone}`} className="text-sm hover:underline">
{contractor.phone}
</a>
</div>
)}
{contractor.email && (
<div className="flex items-center gap-3">
<Mail className="size-4 text-muted-foreground" />
<a href={`mailto:${contractor.email}`} className="text-sm hover:underline">
{contractor.email}
</a>
</div>
)}
{contractor.website && (
<div className="flex items-center gap-3">
<Globe className="size-4 text-muted-foreground" />
<a
href={contractor.website.startsWith("http") ? contractor.website : `https://${contractor.website}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm hover:underline"
>
{contractor.website}
</a>
</div>
)}
{!contractor.phone && !contractor.email && !contractor.website && (
<p className="text-sm text-muted-foreground">No contact information provided.</p>
)}
</CardContent>
</Card>
{/* Specialties */}
{contractor.specialties.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Specialties</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{contractor.specialties.map((s) => (
<Badge key={s.id} variant="secondary">
{s.icon && <span className="mr-1">{s.icon}</span>}
{s.name}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Rating */}
{contractor.rating != null && contractor.rating > 0 && (
<Card>
<CardHeader>
<CardTitle>Rating</CardTitle>
</CardHeader>
<CardContent>
<StarRating value={contractor.rating} readonly />
</CardContent>
</Card>
)}
{/* Notes */}
{contractor.notes && (
<Card>
<CardHeader>
<CardTitle>Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">{contractor.notes}</p>
</CardContent>
</Card>
)}
{/* Linked tasks */}
<Card>
<CardHeader>
<CardTitle>Linked Tasks</CardTitle>
</CardHeader>
<CardContent>
{!tasks || tasks.length === 0 ? (
<p className="text-sm text-muted-foreground">No tasks linked to this contractor.</p>
) : (
<div className="space-y-2">
{tasks.map((task) => (
<div key={task.id}>
<div className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium">{task.title}</p>
<p className="text-xs text-muted-foreground">
{task.residence_name}
{task.due_date && ` - Due ${task.due_date}`}
</p>
</div>
<Badge variant="outline">{task.status}</Badge>
</div>
<Separator />
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Delete confirmation */}
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Contractor"
description={`Are you sure you want to delete "${contractor.name}"? This action cannot be undone.`}
confirmLabel="Delete"
variant="destructive"
loading={deleteContractor.isPending}
onConfirm={handleDelete}
/>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { ContractorForm } from "@/components/contractors/contractor-form";
import { useCreateContractor } from "@/lib/hooks/use-contractors";
import type { ContractorFormValues } from "@/components/contractors/contractor-form";
export default function NewContractorPage() {
const router = useRouter();
const createContractor = useCreateContractor();
function handleSubmit(data: ContractorFormValues) {
createContractor.mutate(data, {
onSuccess: (res) => {
router.push(`/app/contractors/${res.id}`);
},
});
}
return (
<div className="space-y-6 max-w-2xl">
<PageHeader title="New Contractor" />
<ContractorForm onSubmit={handleSubmit} loading={createContractor.isPending} />
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Upload, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { CaseraFileImport } from "@/components/sharing/casera-file-handler";
import { ContractorCard } from "@/components/contractors/contractor-card";
import { ContractorFilters } from "@/components/contractors/contractor-filters";
import { useContractors, useToggleFavorite, useCreateContractor } from "@/lib/hooks/use-contractors";
export default function ContractorsPage() {
const router = useRouter();
const { data: contractors, isLoading, isError, error, refetch } = useContractors();
const toggleFavorite = useToggleFavorite();
const createContractor = useCreateContractor();
const [search, setSearch] = useState("");
const [specialtyId, setSpecialtyId] = useState<number | undefined>(undefined);
const [favoritesOnly, setFavoritesOnly] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const filtered = useMemo(() => {
if (!contractors) return [];
let list = contractors;
// Search filter (name or company)
if (search.trim()) {
const q = search.toLowerCase();
list = list.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.company.toLowerCase().includes(q),
);
}
// Specialty filter
if (specialtyId != null) {
list = list.filter((c) =>
c.specialties.some((s) => s.id === specialtyId),
);
}
// Favorites filter
if (favoritesOnly) {
list = list.filter((c) => c.is_favorite);
}
return list;
}, [contractors, search, specialtyId, favoritesOnly]);
function handleContractorImport(data: unknown) {
setImportError(null);
if (
typeof data === "object" &&
data !== null &&
"type" in data &&
(data as Record<string, unknown>).type === "casera_contractor_share" &&
"contractor" in data
) {
const contractor = (data as Record<string, unknown>).contractor as Record<string, unknown>;
createContractor.mutate(
{
name: (contractor.name as string) ?? "",
company: contractor.company as string | undefined,
phone: contractor.phone as string | undefined,
email: contractor.email as string | undefined,
website: contractor.website as string | undefined,
notes: contractor.notes as string | undefined,
street_address: contractor.street_address as string | undefined,
city: contractor.city as string | undefined,
state_province: contractor.state_province as string | undefined,
postal_code: contractor.postal_code as string | undefined,
specialty_ids: contractor.specialty_ids as number[] | undefined,
rating: contractor.rating as number | undefined,
},
{
onSuccess: () => {
setImportOpen(false);
},
onError: (err) => {
setImportError(
err instanceof Error ? err.message : "Failed to import contractor.",
);
},
},
);
} else {
setImportError("Invalid .casera file. Expected a contractor share file.");
}
}
return (
<div className="space-y-6">
<PageHeader
title="Contractors"
description="Manage your trusted contractors and service providers"
actionLabel="Add Contractor"
onAction={() => router.push("/app/contractors/new")}
>
<Button
variant="outline"
size="sm"
onClick={() => {
setImportError(null);
setImportOpen(true);
}}
>
<Upload className="size-4 mr-2" />
Import .casera
</Button>
</PageHeader>
{isError && (
<ErrorBanner
message={error instanceof Error ? error.message : "Failed to load contractors."}
onRetry={() => refetch()}
/>
)}
{isLoading && <LoadingSkeleton variant="list" count={5} />}
{!isLoading && !isError && contractors && (
<>
<ContractorFilters
search={search}
onSearchChange={setSearch}
specialtyId={specialtyId}
onSpecialtyChange={setSpecialtyId}
favoritesOnly={favoritesOnly}
onFavoritesOnlyChange={setFavoritesOnly}
/>
{filtered.length === 0 ? (
<EmptyState
icon={Wrench}
title="No contractors found"
description={
contractors.length === 0
? "Add your first contractor to keep track of service providers."
: "Try adjusting your search or filters."
}
actionLabel={contractors.length === 0 ? "Add Contractor" : undefined}
onAction={contractors.length === 0 ? () => router.push("/app/contractors/new") : undefined}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((c) => (
<ContractorCard
key={c.id}
contractor={c}
onToggleFavorite={(id) => toggleFavorite.mutate(id)}
/>
))}
</div>
)}
</>
)}
{/* Import .casera dialog */}
<Dialog open={importOpen} onOpenChange={setImportOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Contractor</DialogTitle>
<DialogDescription>
Import a contractor from a .casera file shared with you.
</DialogDescription>
</DialogHeader>
<CaseraFileImport onImport={handleContractorImport} />
{importError && (
<p className="text-sm text-destructive">{importError}</p>
)}
{createContractor.isPending && (
<p className="text-sm text-muted-foreground">Importing...</p>
)}
</DialogContent>
</Dialog>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { DocumentForm } from "@/components/documents/document-form";
import { useDocument, useUpdateDocument } from "@/lib/hooks/use-documents";
interface EditDocumentPageProps {
params: Promise<{ id: string }>;
}
export default function EditDocumentPage({ params }: EditDocumentPageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { data: document, isLoading, error, refetch } = useDocument(id);
const updateDocument = useUpdateDocument(id);
if (isLoading) {
return (
<div className="space-y-6">
<LoadingSkeleton variant="detail" />
</div>
);
}
if (error || !document) {
return (
<div className="space-y-6">
<ErrorBanner
message="Failed to load document."
onRetry={() => refetch()}
/>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Edit Document"
description={document.title}
/>
<DocumentForm
document={document}
loading={updateDocument.isPending}
onSubmit={(data) => {
updateDocument.mutate(data, {
onSuccess: () => {
router.push(`/app/documents/${id}`);
},
});
}}
/>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
"use client";
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import {
Pencil,
Trash2,
Download,
FileText,
} from "lucide-react";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { WarrantyStatus } from "@/components/documents/warranty-status";
import { ImageGallery } from "@/components/documents/image-gallery";
import { useDocument, useDeleteDocument } from "@/lib/hooks/use-documents";
const typeLabels: Record<string, string> = {
general: "General",
warranty: "Warranty",
receipt: "Receipt",
contract: "Contract",
insurance: "Insurance",
manual: "Manual",
};
interface DocumentDetailPageProps {
params: Promise<{ id: string }>;
}
export default function DocumentDetailPage({ params }: DocumentDetailPageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { data: document, isLoading, error, refetch } = useDocument(id);
const deleteDocument = useDeleteDocument();
const [deleteOpen, setDeleteOpen] = useState(false);
if (isLoading) {
return (
<div className="space-y-6">
<LoadingSkeleton variant="detail" />
</div>
);
}
if (error || !document) {
return (
<div className="space-y-6">
<ErrorBanner
message="Failed to load document."
onRetry={() => refetch()}
/>
</div>
);
}
const isWarranty = document.document_type === "warranty";
const warrantyDetails = [
{ label: "Vendor", value: document.vendor },
{ label: "Serial Number", value: document.serial_number },
{ label: "Model Number", value: document.model_number },
{
label: "Purchase Date",
value: document.purchase_date
? format(new Date(document.purchase_date), "MMM d, yyyy")
: undefined,
},
{
label: "Expiry Date",
value: document.expiry_date
? format(new Date(document.expiry_date), "MMM d, yyyy")
: undefined,
},
{
label: "Purchase Price",
value:
document.purchase_price != null
? `$${document.purchase_price.toFixed(2)}`
: undefined,
},
].filter((d) => d.value);
return (
<div className="space-y-6">
<PageHeader title={document.title}>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/documents/${id}/edit`)}
>
<Pencil className="size-4 mr-2" />
Edit
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-4 mr-2" />
Delete
</Button>
</PageHeader>
{/* Type badge & residence */}
<div className="flex flex-wrap items-center gap-3">
<Badge variant="outline">
{typeLabels[document.document_type] ?? document.document_type}
</Badge>
<span className="text-sm text-muted-foreground">
{document.residence_name}
</span>
{isWarranty && <WarrantyStatus expiry_date={document.expiry_date} />}
</div>
{/* Description */}
{document.description && (
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">
Description
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">
{document.description}
</p>
</CardContent>
</Card>
)}
{/* Warranty Details */}
{isWarranty && warrantyDetails.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">
Warranty Details
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{warrantyDetails.map((d) => (
<div key={d.label}>
<p className="text-xs text-muted-foreground">{d.label}</p>
<p className="text-sm font-medium">{d.value}</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Images */}
{document.images && document.images.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">
Images
</CardTitle>
</CardHeader>
<CardContent>
<ImageGallery images={document.images} />
</CardContent>
</Card>
)}
{/* File Download */}
{document.file_url && (
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">
Attached File
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<div className="rounded-md bg-muted p-2">
<FileText className="size-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{document.file_name || "Download file"}
</p>
{document.file_size != null && (
<p className="text-xs text-muted-foreground">
{(document.file_size / 1024).toFixed(0)} KB
</p>
)}
</div>
<Button variant="outline" size="sm" asChild>
<a
href={document.file_url}
target="_blank"
rel="noopener noreferrer"
>
<Download className="size-4 mr-2" />
Download
</a>
</Button>
</div>
</CardContent>
</Card>
)}
{/* Meta info */}
<div className="text-xs text-muted-foreground">
Created {format(new Date(document.created_at), "MMM d, yyyy")} by{" "}
{document.created_by.first_name} {document.created_by.last_name}
</div>
{/* Delete Dialog */}
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Document"
description="Are you sure you want to delete this document? This action cannot be undone."
confirmLabel="Delete"
variant="destructive"
loading={deleteDocument.isPending}
onConfirm={() => {
deleteDocument.mutate(id, {
onSuccess: () => {
router.push("/app/documents");
},
});
}}
/>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { DocumentForm } from "@/components/documents/document-form";
import { useCreateDocument } from "@/lib/hooks/use-documents";
export default function NewDocumentPage() {
const router = useRouter();
const createDocument = useCreateDocument();
return (
<div className="space-y-6">
<PageHeader title="New Document" description="Add a new document" />
<DocumentForm
loading={createDocument.isPending}
onSubmit={(data, file) => {
createDocument.mutate(
{ data, file },
{
onSuccess: (res) => {
router.push(`/app/documents/${res.id}`);
},
},
);
}}
/>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useRouter } from "next/navigation";
import { FileText } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { DocumentCard } from "@/components/documents/document-card";
import { useDocuments, useWarranties } from "@/lib/hooks/use-documents";
export default function DocumentsPage() {
const router = useRouter();
const {
data: documents,
isLoading: documentsLoading,
error: documentsError,
refetch: refetchDocuments,
} = useDocuments();
const {
data: warranties,
isLoading: warrantiesLoading,
error: warrantiesError,
refetch: refetchWarranties,
} = useWarranties();
return (
<div className="space-y-6">
<PageHeader
title="Documents"
description="Manage your property documents and warranties"
actionLabel="Add Document"
onAction={() => router.push("/app/documents/new")}
/>
<Tabs defaultValue="documents">
<TabsList>
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="warranties">Warranties</TabsTrigger>
</TabsList>
<TabsContent value="documents" className="mt-4">
{documentsLoading && <LoadingSkeleton variant="card-grid" />}
{documentsError && (
<ErrorBanner
message="Failed to load documents."
onRetry={() => refetchDocuments()}
/>
)}
{!documentsLoading &&
!documentsError &&
documents &&
documents.length === 0 && (
<EmptyState
icon={FileText}
title="No documents yet"
description="Add your first document to start organizing your property records."
actionLabel="Add Document"
onAction={() => router.push("/app/documents/new")}
/>
)}
{!documentsLoading &&
!documentsError &&
documents &&
documents.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{documents.map((doc) => (
<DocumentCard key={doc.id} document={doc} />
))}
</div>
)}
</TabsContent>
<TabsContent value="warranties" className="mt-4">
{warrantiesLoading && <LoadingSkeleton variant="card-grid" />}
{warrantiesError && (
<ErrorBanner
message="Failed to load warranties."
onRetry={() => refetchWarranties()}
/>
)}
{!warrantiesLoading &&
!warrantiesError &&
warranties &&
warranties.length === 0 && (
<EmptyState
icon={FileText}
title="No warranties yet"
description="Documents with type 'warranty' will appear here."
/>
)}
{!warrantiesLoading &&
!warrantiesError &&
warranties &&
warranties.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{warranties.map((doc) => (
<DocumentCard key={doc.id} document={doc} />
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { Sidebar } from '@/components/layout/sidebar';
import { TopBar } from '@/components/layout/top-bar';
import { MobileNav } from '@/components/layout/mobile-nav';
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-background">
{/* Sidebar - hidden on mobile */}
<Sidebar />
{/* Main content area */}
<div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen">
<TopBar />
<main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6">
{children}
</main>
</div>
{/* Mobile bottom nav */}
<MobileNav />
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useResidences } from "@/lib/hooks/use-residences";
import { useAuthStore } from "@/stores/auth";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { StatsCards } from "@/components/dashboard/stats-cards";
import { TaskCompletionChart } from "@/components/dashboard/task-completion-chart";
import { RecentActivity } from "@/components/dashboard/recent-activity";
export default function DashboardPage() {
const { data: residences, isLoading } = useResidences();
const user = useAuthStore((s) => s.user);
const totalOverdue =
residences?.reduce((sum, r) => sum + r.task_summary.overdue, 0) ?? 0;
const totalDueSoon =
residences?.reduce((sum, r) => sum + r.task_summary.due_soon, 0) ?? 0;
const totalActive =
residences?.reduce((sum, r) => sum + r.task_summary.in_progress, 0) ?? 0;
const totalCompleted =
residences?.reduce((sum, r) => sum + r.task_summary.completed, 0) ?? 0;
return (
<div className="space-y-8">
<h1 className="text-2xl font-bold tracking-tight">
{user?.first_name
? `Welcome back, ${user.first_name}`
: "Dashboard"}
</h1>
{isLoading ? (
<LoadingSkeleton variant="card-grid" count={4} />
) : (
<>
<StatsCards
overdue={totalOverdue}
dueSoon={totalDueSoon}
active={totalActive}
completed={totalCompleted}
/>
<TaskCompletionChart data={[]} />
<RecentActivity />
</>
)}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ResidenceForm } from "@/components/residences/residence-form";
import { useResidence, useUpdateResidence } from "@/lib/hooks/use-residences";
interface EditResidencePageProps {
params: Promise<{ id: string }>;
}
export default function EditResidencePage({ params }: EditResidencePageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { data: residence, isLoading, error, refetch } = useResidence(id);
const updateResidence = useUpdateResidence(id);
if (isLoading) {
return (
<div className="space-y-6">
<LoadingSkeleton variant="detail" />
</div>
);
}
if (error || !residence) {
return (
<div className="space-y-6">
<ErrorBanner
message="Failed to load residence."
onRetry={() => refetch()}
/>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Edit Residence"
description={residence.name}
/>
<ResidenceForm
residence={residence}
loading={updateResidence.isPending}
onSubmit={(data) => {
updateResidence.mutate(data, {
onSuccess: () => {
router.push(`/app/residences/${id}`);
},
});
}}
/>
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
"use client";
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { MapPin, Pencil, Share2, Trash2, FileDown } from "lucide-react";
import * as residencesApi from "@/lib/api/residences";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/shared/page-header";
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";
interface ResidenceDetailPageProps {
params: Promise<{ id: string }>;
}
export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { data: residence, isLoading, error, refetch } = useResidence(id);
const { data: residences } = useResidences();
const deleteResidence = useDeleteResidence();
const [deleteOpen, setDeleteOpen] = useState(false);
const [reportLoading, setReportLoading] = useState(false);
const [reportMessage, setReportMessage] = useState<string | null>(null);
const handleGenerateReport = async () => {
setReportLoading(true);
setReportMessage(null);
try {
const result = await residencesApi.generateTasksReport(id);
setReportMessage(result.message || "Report sent to your email!");
} catch {
setReportMessage("Failed to generate report.");
} finally {
setReportLoading(false);
}
};
// Find the task summary from the residences list
const myResidence = residences?.find((r) => r.residence.id === id);
const taskSummary = myResidence?.task_summary;
if (isLoading) {
return (
<div className="space-y-6">
<LoadingSkeleton variant="detail" />
</div>
);
}
if (error || !residence) {
return (
<div className="space-y-6">
<ErrorBanner
message="Failed to load residence."
onRetry={() => refetch()}
/>
</div>
);
}
const address = [
residence.street_address,
residence.apartment_unit,
residence.city,
residence.state_province,
residence.postal_code,
residence.country,
]
.filter(Boolean)
.join(", ");
const details = [
{ label: "Bedrooms", value: residence.bedrooms },
{ label: "Bathrooms", value: residence.bathrooms },
{ label: "Sq. Footage", value: residence.square_footage?.toLocaleString() },
{ label: "Year Built", value: residence.year_built },
{ label: "Property Type", value: residence.property_type?.name },
].filter((d) => d.value != null);
return (
<div className="space-y-6">
<PageHeader title={residence.name}>
{residence.is_owner && (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/residences/${id}/share`)}
>
<Share2 className="size-4 mr-2" />
Share
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleGenerateReport}
disabled={reportLoading}
>
<FileDown className="size-4 mr-2" />
{reportLoading ? "Generating..." : "Report"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/residences/${id}/edit`)}
>
<Pencil className="size-4 mr-2" />
Edit
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-4 mr-2" />
Delete
</Button>
</PageHeader>
{/* Report Message */}
{reportMessage && (
<div className="rounded-md border px-4 py-3 text-sm">
{reportMessage}
</div>
)}
{/* Address */}
{address && (
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="size-4 shrink-0" />
<span>{address}</span>
</div>
)}
{/* Task Summary */}
{taskSummary && (
<ResidenceSummary
totalTasks={taskSummary.total}
inProgress={taskSummary.in_progress}
userCount={residence.user_count}
/>
)}
{/* Description */}
{residence.description && (
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Description</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">{residence.description}</p>
</CardContent>
</Card>
)}
{/* Property Details */}
{details.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Property Details</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{details.map((d) => (
<div key={d.label}>
<p className="text-xs text-muted-foreground">{d.label}</p>
<p className="text-sm font-medium">{d.value}</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Delete Dialog */}
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Residence"
description="Are you sure you want to delete this residence? This action cannot be undone."
confirmLabel="Delete"
variant="destructive"
loading={deleteResidence.isPending}
onConfirm={() => {
deleteResidence.mutate(id, {
onSuccess: () => {
router.push("/app/residences");
},
});
}}
/>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ShareCodeDisplay } from "@/components/sharing/share-code-display";
import { UserManagement } from "@/components/sharing/user-management";
import { CaseraFileExport } from "@/components/sharing/casera-file-handler";
import { useResidence } from "@/lib/hooks/use-residences";
interface SharePageProps {
params: Promise<{ id: string }>;
}
export default function ResidenceSharePage({ params }: SharePageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { data: residence, isLoading, error, refetch } = useResidence(id);
if (isLoading) {
return (
<div className="space-y-6">
<LoadingSkeleton variant="detail" />
</div>
);
}
if (error || !residence) {
return (
<div className="space-y-6">
<ErrorBanner
message="Failed to load residence."
onRetry={() => refetch()}
/>
</div>
);
}
// Build the exportable residence data
const exportData = {
type: "casera_residence_share",
version: 1,
residence: {
name: residence.name,
property_type_id: residence.property_type_id,
street_address: residence.street_address,
apartment_unit: residence.apartment_unit,
city: residence.city,
state_province: residence.state_province,
postal_code: residence.postal_code,
country: residence.country,
bedrooms: residence.bedrooms,
bathrooms: residence.bathrooms,
square_footage: residence.square_footage,
lot_size: residence.lot_size,
year_built: residence.year_built,
description: residence.description,
},
exported_at: new Date().toISOString(),
};
const safeFilename = residence.name.replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase();
return (
<div className="space-y-6 max-w-2xl">
<PageHeader title={`Share "${residence.name}"`}>
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/app/residences/${id}`)}
>
<ArrowLeft className="size-4 mr-2" />
Back
</Button>
</PageHeader>
{/* Share code section — only owner can manage */}
{residence.is_owner && (
<ShareCodeDisplay residenceId={id} />
)}
{/* Export .casera file */}
{residence.is_owner && (
<div className="flex items-center gap-3">
<CaseraFileExport
data={exportData}
filename={`${safeFilename}-residence`}
label="Export Residence (.casera)"
/>
<p className="text-sm text-muted-foreground">
Download residence data as a portable .casera file.
</p>
</div>
)}
{/* User / member management */}
<UserManagement residenceId={id} />
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Home } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { PageHeader } from "@/components/shared/page-header";
import { ErrorBanner } from "@/components/shared/error-banner";
import { CaseraFileImport } from "@/components/sharing/casera-file-handler";
import { useJoinResidence } from "@/lib/hooks/use-sharing";
export default function JoinResidencePage() {
const router = useRouter();
const joinResidence = useJoinResidence();
const [code, setCode] = useState("");
const [fileError, setFileError] = useState<string | null>(null);
function handleSubmitCode(e: React.FormEvent) {
e.preventDefault();
const trimmed = code.trim();
if (!trimmed) return;
joinResidence.mutate(trimmed, {
onSuccess: () => {
router.push("/app/residences");
},
});
}
function handleFileImport(data: unknown) {
setFileError(null);
// Validate that the imported data has a code field
if (
typeof data === "object" &&
data !== null &&
"code" in data &&
typeof (data as Record<string, unknown>).code === "string"
) {
const importedCode = (data as Record<string, unknown>).code as string;
joinResidence.mutate(importedCode, {
onSuccess: () => {
router.push("/app/residences");
},
});
} else {
setFileError(
"Invalid .casera file. Expected a share package with a code field.",
);
}
}
return (
<div className="space-y-6 max-w-lg mx-auto">
<PageHeader title="Join a Residence" />
{joinResidence.isError && (
<ErrorBanner
message={
joinResidence.error instanceof Error
? joinResidence.error.message
: "Failed to join residence. Please check the code and try again."
}
/>
)}
{/* Code entry */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Home className="size-5" />
Enter Share Code
</CardTitle>
<CardDescription>
Enter the 6-character code you received from the residence owner.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmitCode} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="share-code">Share Code</Label>
<Input
id="share-code"
placeholder="ABC123"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
maxLength={6}
className="text-center text-lg font-mono tracking-widest"
autoComplete="off"
/>
</div>
<Button
type="submit"
className="w-full"
disabled={code.trim().length === 0 || joinResidence.isPending}
>
{joinResidence.isPending ? "Joining..." : "Join Residence"}
</Button>
</form>
</CardContent>
</Card>
{/* Divider */}
<div className="flex items-center gap-4">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground uppercase">or</span>
<Separator className="flex-1" />
</div>
{/* File import */}
<Card>
<CardHeader>
<CardTitle>Import .casera File</CardTitle>
<CardDescription>
If you received a .casera share package file, import it here.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CaseraFileImport onImport={handleFileImport} />
{fileError && (
<p className="text-sm text-destructive">{fileError}</p>
)}
</CardContent>
</Card>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { ResidenceForm } from "@/components/residences/residence-form";
import { useCreateResidence } from "@/lib/hooks/use-residences";
export default function NewResidencePage() {
const router = useRouter();
const createResidence = useCreateResidence();
return (
<div className="space-y-6">
<PageHeader title="New Residence" description="Add a new property" />
<ResidenceForm
loading={createResidence.isPending}
onSubmit={(data) => {
createResidence.mutate(data, {
onSuccess: (res) => {
router.push(`/app/residences/${res.id}`);
},
});
}}
/>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useRouter } from "next/navigation";
import { Home } from "lucide-react";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { ResidenceCard } from "@/components/residences/residence-card";
import { useResidences } from "@/lib/hooks/use-residences";
export default function ResidencesPage() {
const router = useRouter();
const { data: residences, isLoading, error, refetch } = useResidences();
return (
<div className="space-y-6">
<PageHeader
title="Residences"
description="Manage your properties"
actionLabel="Add Residence"
onAction={() => router.push("/app/residences/new")}
/>
{isLoading && <LoadingSkeleton variant="card-grid" />}
{error && (
<ErrorBanner
message="Failed to load residences."
onRetry={() => refetch()}
/>
)}
{!isLoading && !error && residences && residences.length === 0 && (
<EmptyState
icon={Home}
title="No residences yet"
description="Add your first property to start tracking tasks and maintenance."
actionLabel="Add Residence"
onAction={() => router.push("/app/residences/new")}
/>
)}
{!isLoading && !error && residences && residences.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{residences.map((item) => (
<ResidenceCard key={item.residence.id} data={item} />
))}
</div>
)}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { User, Bell, CreditCard } from "lucide-react";
const settingsNav = [
{ label: "Profile", href: "/app/settings/profile", icon: User },
{ label: "Notifications", href: "/app/settings/notifications", icon: Bell },
{ label: "Subscription", href: "/app/settings/subscription", icon: CreditCard },
];
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<div className="flex flex-col sm:flex-row gap-6">
<nav className="flex sm:flex-col gap-1 sm:w-48 shrink-0">
{settingsNav.map((item) => (
<Link key={item.href} href={item.href}
className={cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors",
"hover:bg-accent hover:text-accent-foreground",
pathname === item.href ? "bg-accent text-accent-foreground" : "text-muted-foreground"
)}>
<item.icon className="size-4" />
{item.label}
</Link>
))}
</nav>
<div className="flex-1 min-w-0">{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,7 @@
"use client";
import { NotificationPreferences } from "@/components/settings/notification-preferences";
export default function NotificationsSettingsPage() {
return <NotificationPreferences />;
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function SettingsPage() {
redirect("/app/settings/profile");
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { ProfileForm } from "@/components/settings/profile-form";
import { ChangePasswordForm } from "@/components/settings/change-password-form";
import { ThemePicker } from "@/components/settings/theme-picker";
import { DeleteAccountSection } from "@/components/settings/delete-account-section";
export default function ProfileSettingsPage() {
return (
<div className="space-y-6">
<ProfileForm />
<ChangePasswordForm />
<ThemePicker />
<DeleteAccountSection />
</div>
);
}
@@ -0,0 +1,13 @@
"use client";
import { SubscriptionStatus } from "@/components/settings/subscription-status";
import { FeatureComparison } from "@/components/settings/feature-comparison";
export default function SubscriptionSettingsPage() {
return (
<div className="space-y-6">
<SubscriptionStatus />
<FeatureComparison />
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { Card, CardContent } from "@/components/ui/card";
import { TaskCompletionForm } from "@/components/tasks/task-completion-form";
import { useTask, useCreateCompletion } from "@/lib/hooks/use-tasks";
export default function CompleteTaskPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const taskId = Number(id);
const router = useRouter();
const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const createCompletion = useCreateCompletion();
if (isLoading) {
return <LoadingSkeleton variant="detail" />;
}
if (isError) {
return (
<ErrorBanner
message={
error instanceof Error ? error.message : "Failed to load task"
}
onRetry={() => refetch()}
/>
);
}
if (!task) return null;
return (
<div className="space-y-6">
<PageHeader
title="Complete Task"
description={task.title}
/>
<Card>
<CardContent>
<TaskCompletionForm
onSubmit={(data, images) => {
createCompletion.mutate(
{
data: {
task_id: taskId,
completed_at: data.completed_at,
actual_cost: data.actual_cost,
notes: data.notes,
rating: data.rating,
},
images,
},
{
onSuccess: () => router.push(`/app/tasks/${taskId}`),
},
);
}}
isSubmitting={createCompletion.isPending}
/>
</CardContent>
</Card>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { Card, CardContent } from "@/components/ui/card";
import { TaskForm } from "@/components/tasks/task-form";
import { useTask, useUpdateTask } from "@/lib/hooks/use-tasks";
export default function EditTaskPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const taskId = Number(id);
const router = useRouter();
const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const updateTask = useUpdateTask(taskId);
if (isLoading) {
return <LoadingSkeleton variant="detail" />;
}
if (isError) {
return (
<ErrorBanner
message={
error instanceof Error ? error.message : "Failed to load task"
}
onRetry={() => refetch()}
/>
);
}
if (!task) return null;
return (
<div className="space-y-6">
<PageHeader title="Edit Task" />
<Card>
<CardContent>
<TaskForm
task={task}
onSubmit={(data) => {
updateTask.mutate(data, {
onSuccess: () => router.push(`/app/tasks/${taskId}`),
});
}}
isSubmitting={updateTask.isPending}
/>
</CardContent>
</Card>
</div>
);
}
+253
View File
@@ -0,0 +1,253 @@
"use client";
import { use } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { StarRating } from "@/components/shared/star-rating";
import { TaskActionsMenu } from "@/components/tasks/task-actions-menu";
import { useTask, useTaskCompletions } from "@/lib/hooks/use-tasks";
import {
Calendar,
DollarSign,
Repeat,
User,
Wrench,
Home,
} from "lucide-react";
export default function TaskDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const taskId = Number(id);
const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const { data: completions } = useTaskCompletions(taskId);
if (isLoading) {
return <LoadingSkeleton variant="detail" />;
}
if (isError) {
return (
<ErrorBanner
message={error instanceof Error ? error.message : "Failed to load task"}
onRetry={() => refetch()}
/>
);
}
if (!task) return null;
const statusLabel = task.is_cancelled
? "Cancelled"
: task.is_archived
? "Archived"
: task.in_progress
? "In Progress"
: "Active";
const statusVariant = task.is_cancelled
? "destructive"
: task.is_archived
? "secondary"
: task.in_progress
? "default"
: "outline";
return (
<div className="space-y-6">
<PageHeader title={task.title}>
<TaskActionsMenu taskId={task.id} />
</PageHeader>
{/* Status & badges */}
<div className="flex flex-wrap gap-2">
<Badge variant={statusVariant}>{statusLabel}</Badge>
{task.priority && (
<Badge variant="outline">
{task.priority.icon && <span className="mr-1">{task.priority.icon}</span>}
{task.priority.name}
</Badge>
)}
{task.category && (
<Badge variant="secondary">
{task.category.icon && <span className="mr-1">{task.category.icon}</span>}
{task.category.name}
</Badge>
)}
{task.frequency && (
<Badge variant="outline">
<Repeat className="size-3 mr-1" />
{task.frequency.name}
</Badge>
)}
</div>
{/* Metadata */}
<Card>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div className="flex items-center gap-2">
<Home className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Residence:</span>
<span>{task.residence_name}</span>
</div>
{task.due_date && (
<div className="flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Due Date:</span>
<span>{new Date(task.due_date).toLocaleDateString()}</span>
</div>
)}
{task.next_due_date && (
<div className="flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Next Due:</span>
<span>
{new Date(task.next_due_date).toLocaleDateString()}
</span>
</div>
)}
{task.estimated_cost != null && task.estimated_cost > 0 && (
<div className="flex items-center gap-2">
<DollarSign className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Estimated Cost:</span>
<span>${task.estimated_cost.toFixed(2)}</span>
</div>
)}
{task.actual_cost != null && task.actual_cost > 0 && (
<div className="flex items-center gap-2">
<DollarSign className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Actual Cost:</span>
<span>${task.actual_cost.toFixed(2)}</span>
</div>
)}
{task.contractor && (
<div className="flex items-center gap-2">
<Wrench className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Contractor:</span>
<span>
{task.contractor.name}
{task.contractor.company && ` (${task.contractor.company})`}
</span>
</div>
)}
{task.assigned_to && (
<div className="flex items-center gap-2">
<User className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Assigned To:</span>
<span>
{task.assigned_to.first_name} {task.assigned_to.last_name}
</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Completions:</span>
<span>{task.completion_count}</span>
</div>
{task.last_completed_at && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Last Completed:</span>
<span>
{new Date(task.last_completed_at).toLocaleDateString()}
</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Description */}
{task.description && (
<Card>
<CardHeader>
<CardTitle>Description</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">{task.description}</p>
</CardContent>
</Card>
)}
{/* Completion history */}
{completions && completions.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Completion History</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{completions.map((completion) => (
<div
key={completion.id}
className="border rounded-lg p-3 space-y-2"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{new Date(completion.completed_at).toLocaleDateString()}{" "}
{new Date(completion.completed_at).toLocaleTimeString()}
</span>
{completion.rating != null && completion.rating > 0 && (
<StarRating
value={completion.rating}
readonly
size="sm"
/>
)}
</div>
{completion.completed_by && (
<p className="text-xs text-muted-foreground">
Completed by {completion.completed_by.first_name}{" "}
{completion.completed_by.last_name}
</p>
)}
{completion.notes && (
<p className="text-sm">{completion.notes}</p>
)}
{completion.actual_cost != null &&
completion.actual_cost > 0 && (
<p className="text-sm text-muted-foreground">
Cost: ${completion.actual_cost.toFixed(2)}
</p>
)}
{completion.images.length > 0 && (
<div className="flex gap-2 flex-wrap">
{completion.images.map((img) => (
<img
key={img.id}
src={img.image_url}
alt={img.caption || "Completion photo"}
className="size-20 rounded-md object-cover border"
/>
))}
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { TaskForm } from "@/components/tasks/task-form";
import { useCreateTask } from "@/lib/hooks/use-tasks";
export default function NewTaskPage() {
const router = useRouter();
const createTask = useCreateTask();
return (
<div className="space-y-6">
<PageHeader title="New Task" />
<Card>
<CardContent>
<TaskForm
onSubmit={(data) => {
createTask.mutate(data, {
onSuccess: () => router.push("/app/tasks"),
});
}}
isSubmitting={createTask.isPending}
/>
</CardContent>
</Card>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ClipboardList } from "lucide-react";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { LookupSelect } from "@/components/shared/lookup-select";
import { KanbanBoard } from "@/components/tasks/kanban-board";
import { useTasks, useTasksByResidence } from "@/lib/hooks/use-tasks";
import { useResidences } from "@/lib/hooks/use-residences";
export default function TasksPage() {
const router = useRouter();
const [selectedResidenceId, setSelectedResidenceId] = useState<
number | undefined
>();
const { data: residences } = useResidences();
const allTasks = useTasks();
const filteredTasks = useTasksByResidence(selectedResidenceId ?? 0);
const activeQuery = selectedResidenceId ? filteredTasks : allTasks;
const { data, isLoading, isError, error, refetch } = activeQuery;
const residenceItems = (residences ?? []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
const isEmpty =
data && data.columns.every((col) => col.tasks.length === 0);
return (
<div className="space-y-6">
<PageHeader
title="Tasks"
description="Manage your home maintenance tasks"
actionLabel="New Task"
onAction={() => router.push("/app/tasks/new")}
>
{residenceItems.length > 1 && (
<LookupSelect
items={[{ id: 0, name: "All Residences" }, ...residenceItems]}
value={selectedResidenceId ?? 0}
onValueChange={(v) =>
setSelectedResidenceId(v === 0 ? undefined : v)
}
placeholder="Filter by residence..."
/>
)}
</PageHeader>
{isLoading && <LoadingSkeleton variant="kanban" count={5} />}
{isError && (
<ErrorBanner
message={error instanceof Error ? error.message : "Failed to load tasks"}
onRetry={() => refetch()}
/>
)}
{!isLoading && !isError && isEmpty && (
<EmptyState
icon={ClipboardList}
title="No tasks yet"
description="Create your first task to start tracking home maintenance."
actionLabel="New Task"
onAction={() => router.push("/app/tasks/new")}
/>
)}
{!isLoading && !isError && data && !isEmpty && (
<KanbanBoard data={data} />
)}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+73
View File
@@ -0,0 +1,73 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "../styles/themes.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
/* App-specific theme-aware Tailwind utilities */
--color-bg-primary: var(--color-bg-primary);
--color-bg-secondary: var(--color-bg-secondary);
--color-text-primary: var(--color-text-primary);
--color-text-secondary: var(--color-text-secondary);
--color-text-on-primary: var(--color-text-on-primary);
--color-app-primary: var(--color-primary);
--color-app-secondary: var(--color-secondary);
--color-app-accent: var(--color-accent);
--color-app-error: var(--color-error);
}
:root {
--radius: 0.625rem;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/lib/themes/theme-provider";
import { QueryProvider } from "@/lib/query/query-provider";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Casera",
description: "Property management platform",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<QueryProvider>
<ThemeProvider>{children}</ThemeProvider>
</QueryProvider>
</body>
</html>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useOnboardingStore } from "@/stores/onboarding";
import { cn } from "@/lib/utils";
function StepDots() {
const { currentStep, path } = useOnboardingStore();
// Total steps depends on path:
// Create path: Welcome (0) -> Choose (1) -> Create Residence (2) -> First Task (3) -> Complete (4)
// Join path: Welcome (0) -> Choose (1) -> Join Residence (2) -> Complete (3)
// Before path chosen: 4 dots (default)
const totalSteps = path === "join" ? 4 : 5;
return (
<div className="flex items-center justify-center gap-2">
{Array.from({ length: totalSteps }).map((_, i) => (
<div
key={i}
className={cn(
"h-2 rounded-full transition-all duration-300",
i === currentStep
? "w-6 bg-primary"
: i < currentStep
? "w-2 bg-primary/60"
: "w-2 bg-muted-foreground/30"
)}
/>
))}
</div>
);
}
export default function OnboardingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen flex-col items-center bg-background px-4 py-12">
{/* Logo */}
<div className="mb-2">
<h1 className="text-2xl font-bold tracking-tight text-primary">
Casera
</h1>
</div>
{/* Progress dots */}
<div className="mb-8">
<StepDots />
</div>
{/* Content */}
<div className="w-full max-w-lg">{children}</div>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useOnboardingStore } from "@/stores/onboarding";
import { WelcomeStep } from "@/components/onboarding/welcome-step";
import { ChoosePathStep } from "@/components/onboarding/choose-path-step";
import { CreateResidenceStep } from "@/components/onboarding/create-residence-step";
import { JoinResidenceStep } from "@/components/onboarding/join-residence-step";
import { FirstTaskStep } from "@/components/onboarding/first-task-step";
import { CompleteStep } from "@/components/onboarding/complete-step";
export default function OnboardingPage() {
const { currentStep, path } = useOnboardingStore();
// Step flow:
// 0: Welcome
// 1: Choose path (create or join)
// 2: Create Residence (path=create) or Join Residence (path=join)
// 3: First Task (path=create only) or Complete (path=join)
// 4: Complete (path=create)
if (currentStep === 0) {
return <WelcomeStep />;
}
if (currentStep === 1) {
return <ChoosePathStep />;
}
if (currentStep === 2) {
if (path === "create") {
return <CreateResidenceStep />;
}
if (path === "join") {
return <JoinResidenceStep />;
}
// Fallback if path not set (shouldn't happen)
return <ChoosePathStep />;
}
if (currentStep === 3) {
if (path === "create") {
return <FirstTaskStep />;
}
// path === "join" reaches complete at step 3
return <CompleteStep />;
}
if (currentStep === 4) {
return <CompleteStep />;
}
// Fallback
return <WelcomeStep />;
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/app');
}