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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SettingsPage() {
|
||||
redirect("/app/settings/profile");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/app');
|
||||
}
|
||||
Reference in New Issue
Block a user