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');
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Phone, Mail, Star } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardAction } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ContractorResponse } from "@/lib/api/contractors";
|
||||
|
||||
interface ContractorCardProps {
|
||||
contractor: ContractorResponse;
|
||||
onToggleFavorite: (id: number) => void;
|
||||
}
|
||||
|
||||
export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardProps) {
|
||||
return (
|
||||
<Card className="transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<Link href={`/app/contractors/${contractor.id}`} className="hover:underline">
|
||||
<CardTitle>{contractor.name}</CardTitle>
|
||||
</Link>
|
||||
{contractor.company && (
|
||||
<CardDescription>{contractor.company}</CardDescription>
|
||||
)}
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggleFavorite(contractor.id);
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
className={
|
||||
contractor.is_favorite
|
||||
? "size-4 fill-yellow-400 text-yellow-400"
|
||||
: "size-4 text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{contractor.specialties.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{contractor.specialties.map((s) => (
|
||||
<Badge key={s.id} variant="secondary">
|
||||
{s.icon && <span className="mr-1">{s.icon}</span>}
|
||||
{s.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{contractor.phone && (
|
||||
<Button variant="outline" size="icon" className="size-8" asChild>
|
||||
<a href={`tel:${contractor.phone}`} onClick={(e) => e.stopPropagation()}>
|
||||
<Phone className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{contractor.email && (
|
||||
<Button variant="outline" size="icon" className="size-8" asChild>
|
||||
<a href={`mailto:${contractor.email}`} onClick={(e) => e.stopPropagation()}>
|
||||
<Mail className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LookupSelect } from "@/components/shared/lookup-select";
|
||||
import { useContractorSpecialties } from "@/lib/hooks/use-lookups";
|
||||
import { Search, Star } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ContractorFiltersProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
specialtyId: number | undefined;
|
||||
onSpecialtyChange: (value: number | undefined) => void;
|
||||
favoritesOnly: boolean;
|
||||
onFavoritesOnlyChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function ContractorFilters({
|
||||
search,
|
||||
onSearchChange,
|
||||
specialtyId,
|
||||
onSpecialtyChange,
|
||||
favoritesOnly,
|
||||
onFavoritesOnlyChange,
|
||||
}: ContractorFiltersProps) {
|
||||
const { data: specialties } = useContractorSpecialties();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name or company..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Specialty filter */}
|
||||
<div className="w-full sm:w-48">
|
||||
<LookupSelect
|
||||
items={specialties}
|
||||
value={specialtyId}
|
||||
onValueChange={onSpecialtyChange}
|
||||
placeholder="All specialties"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Favorites toggle */}
|
||||
<Button
|
||||
variant={favoritesOnly ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onFavoritesOnlyChange(!favoritesOnly)}
|
||||
className={cn("gap-1.5", favoritesOnly && "bg-yellow-500 hover:bg-yellow-600 text-white")}
|
||||
>
|
||||
<Star className={cn("size-4", favoritesOnly && "fill-white")} />
|
||||
Favorites
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FormField } from "@/components/shared/form-field";
|
||||
import { LookupSelect } from "@/components/shared/lookup-select";
|
||||
import { StarRating } from "@/components/shared/star-rating";
|
||||
import { useContractorSpecialties } from "@/lib/hooks/use-lookups";
|
||||
import { useResidences } from "@/lib/hooks/use-residences";
|
||||
import type { ContractorResponse } from "@/lib/api/contractors";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const contractorSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
company: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
email: z.string().email("Invalid email address").or(z.literal("")).optional(),
|
||||
website: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
residence_id: z.number().optional(),
|
||||
specialty_ids: z.array(z.number()).optional(),
|
||||
is_favorite: z.boolean().optional(),
|
||||
rating: z.number().optional(),
|
||||
});
|
||||
|
||||
export type ContractorFormValues = z.infer<typeof contractorSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ContractorFormProps {
|
||||
contractor?: ContractorResponse;
|
||||
onSubmit: (data: ContractorFormValues) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ContractorForm({ contractor, onSubmit, loading }: ContractorFormProps) {
|
||||
const { data: specialties } = useContractorSpecialties();
|
||||
const { data: residencesData } = useResidences();
|
||||
|
||||
const residenceItems = (residencesData ?? []).map((r) => ({
|
||||
id: r.residence.id,
|
||||
name: r.residence.name,
|
||||
}));
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<ContractorFormValues>({
|
||||
resolver: zodResolver(contractorSchema),
|
||||
defaultValues: {
|
||||
name: contractor?.name ?? "",
|
||||
company: contractor?.company ?? "",
|
||||
phone: contractor?.phone ?? "",
|
||||
email: contractor?.email ?? "",
|
||||
website: contractor?.website ?? "",
|
||||
notes: contractor?.notes ?? "",
|
||||
residence_id: contractor?.residence_id ?? undefined,
|
||||
specialty_ids: contractor?.specialties.map((s) => s.id) ?? [],
|
||||
is_favorite: contractor?.is_favorite ?? false,
|
||||
rating: contractor?.rating ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedSpecialtyIds = watch("specialty_ids") ?? [];
|
||||
const rating = watch("rating") ?? 0;
|
||||
|
||||
function toggleSpecialty(id: number) {
|
||||
const current = selectedSpecialtyIds;
|
||||
const next = current.includes(id)
|
||||
? current.filter((sid) => sid !== id)
|
||||
: [...current, id];
|
||||
setValue("specialty_ids", next, { shouldDirty: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Name (required) */}
|
||||
<FormField label="Name" htmlFor="name" required error={errors.name?.message}>
|
||||
<Input id="name" placeholder="e.g. John's Plumbing" {...register("name")} />
|
||||
</FormField>
|
||||
|
||||
{/* Company */}
|
||||
<FormField label="Company" htmlFor="company" error={errors.company?.message}>
|
||||
<Input id="company" placeholder="Company name" {...register("company")} />
|
||||
</FormField>
|
||||
|
||||
{/* Contact fields grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField label="Phone" htmlFor="phone" error={errors.phone?.message}>
|
||||
<Input id="phone" type="tel" placeholder="(555) 123-4567" {...register("phone")} />
|
||||
</FormField>
|
||||
<FormField label="Email" htmlFor="email" error={errors.email?.message}>
|
||||
<Input id="email" type="email" placeholder="contractor@example.com" {...register("email")} />
|
||||
</FormField>
|
||||
<FormField label="Website" htmlFor="website" error={errors.website?.message} className="sm:col-span-2">
|
||||
<Input id="website" placeholder="https://example.com" {...register("website")} />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* Residence */}
|
||||
<FormField label="Residence" htmlFor="residence_id">
|
||||
<LookupSelect
|
||||
items={residenceItems}
|
||||
value={watch("residence_id")}
|
||||
onValueChange={(v) => setValue("residence_id", v, { shouldDirty: true })}
|
||||
placeholder="Select a residence (optional)"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Specialties */}
|
||||
<FormField label="Specialties" htmlFor="specialties">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{specialties.map((s) => {
|
||||
const selected = selectedSpecialtyIds.includes(s.id);
|
||||
return (
|
||||
<Badge
|
||||
key={s.id}
|
||||
variant={selected ? "default" : "outline"}
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => toggleSpecialty(s.id)}
|
||||
>
|
||||
{s.icon && <span className="mr-1">{s.icon}</span>}
|
||||
{s.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
{/* Rating */}
|
||||
<FormField label="Rating" htmlFor="rating">
|
||||
<StarRating
|
||||
value={rating}
|
||||
onChange={(v) => setValue("rating", v, { shouldDirty: true })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Notes */}
|
||||
<FormField label="Notes" htmlFor="notes" error={errors.notes?.message}>
|
||||
<Textarea id="notes" placeholder="Any additional notes..." rows={3} {...register("notes")} />
|
||||
</FormField>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Saving..." : contractor ? "Update Contractor" : "Create Contractor"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { useNotifications } from "@/lib/hooks/use-notifications";
|
||||
|
||||
export function RecentActivity() {
|
||||
const { data, isLoading } = useNotifications(5);
|
||||
|
||||
const notifications = data?.results ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Recent Activity</span>
|
||||
<Link
|
||||
href="/app/settings/notifications"
|
||||
className="text-sm font-normal text-primary hover:underline"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3 animate-pulse">
|
||||
<div className="size-8 rounded-full bg-muted" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-1/2 rounded bg-muted" />
|
||||
<div className="h-3 w-3/4 rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
No recent activity
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{notifications.map((notification) => (
|
||||
<div key={notification.id} className="flex gap-3 items-start">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<Bell className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-tight">
|
||||
{notification.title}
|
||||
</p>
|
||||
{notification.body && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{notification.body}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatDistanceToNow(new Date(notification.created_at), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, Clock, ClipboardList, CheckCircle2 } from "lucide-react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface StatsCardsProps {
|
||||
overdue: number;
|
||||
dueSoon: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
key: "overdue",
|
||||
label: "Overdue",
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-500",
|
||||
prop: "overdue" as const,
|
||||
},
|
||||
{
|
||||
key: "dueSoon",
|
||||
label: "Due Soon",
|
||||
icon: Clock,
|
||||
color: "text-orange-500",
|
||||
prop: "dueSoon" as const,
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "Active",
|
||||
icon: ClipboardList,
|
||||
color: "text-blue-500",
|
||||
prop: "active" as const,
|
||||
},
|
||||
{
|
||||
key: "completed",
|
||||
label: "Completed",
|
||||
icon: CheckCircle2,
|
||||
color: "text-green-500",
|
||||
prop: "completed" as const,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function StatsCards({ overdue, dueSoon, active, completed }: StatsCardsProps) {
|
||||
const values: Record<string, number> = { overdue, dueSoon, active, completed };
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((stat) => (
|
||||
<Link key={stat.key} href="/app/tasks">
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<stat.icon className={`size-4 ${stat.color}`} />
|
||||
{stat.label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{values[stat.prop]}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface TaskCompletionChartProps {
|
||||
data: { date: string; count: number }[];
|
||||
}
|
||||
|
||||
export function TaskCompletionChart({ data }: TaskCompletionChartProps) {
|
||||
const hasData = data && data.length > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Task Completions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasData ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="hsl(var(--primary))"
|
||||
fill="hsl(var(--primary))"
|
||||
fillOpacity={0.2}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
No completion data yet
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Link from "next/link";
|
||||
import { FileText, FileImage, File, FileSpreadsheet } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { WarrantyStatus } from "@/components/documents/warranty-status";
|
||||
import type { DocumentResponse } from "@/lib/api/documents";
|
||||
|
||||
interface DocumentCardProps {
|
||||
document: DocumentResponse;
|
||||
}
|
||||
|
||||
function getFileIcon(mimeType: string) {
|
||||
if (mimeType.startsWith("image/")) return FileImage;
|
||||
if (mimeType.includes("spreadsheet") || mimeType.includes("csv") || mimeType.includes("excel")) return FileSpreadsheet;
|
||||
if (mimeType.includes("pdf") || mimeType.includes("text")) return FileText;
|
||||
return File;
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
general: "General",
|
||||
warranty: "Warranty",
|
||||
receipt: "Receipt",
|
||||
contract: "Contract",
|
||||
insurance: "Insurance",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
export function DocumentCard({ document: doc }: DocumentCardProps) {
|
||||
const Icon = getFileIcon(doc.mime_type);
|
||||
|
||||
return (
|
||||
<Link href={`/app/documents/${doc.id}`} className="block">
|
||||
<Card className="transition-colors hover:border-primary/40">
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-md bg-muted p-2 shrink-0">
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base truncate">{doc.title}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
||||
{doc.residence_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{typeLabels[doc.document_type] ?? doc.document_type}
|
||||
</Badge>
|
||||
{doc.document_type === "warranty" && (
|
||||
<WarrantyStatus expiry_date={doc.expiry_date} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
Created {format(new Date(doc.created_at), "MMM d, yyyy")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { FormField } from "@/components/shared/form-field";
|
||||
import { LookupSelect } from "@/components/shared/lookup-select";
|
||||
import { FileUpload } from "@/components/shared/file-upload";
|
||||
import { CurrencyInput } from "@/components/shared/currency-input";
|
||||
import { useResidences } from "@/lib/hooks/use-residences";
|
||||
import type { DocumentResponse, DocumentType } from "@/lib/api/documents";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const documentSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
residence_id: z.number({ error: "Residence is required" }),
|
||||
description: z.string().optional(),
|
||||
document_type: z
|
||||
.enum(["general", "warranty", "receipt", "contract", "insurance", "manual"])
|
||||
.optional(),
|
||||
vendor: z.string().optional(),
|
||||
serial_number: z.string().optional(),
|
||||
model_number: z.string().optional(),
|
||||
purchase_date: z.string().optional(),
|
||||
expiry_date: z.string().optional(),
|
||||
purchase_price: z.number().optional(),
|
||||
});
|
||||
|
||||
type DocumentFormData = z.infer<typeof documentSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DocumentFormProps {
|
||||
document?: DocumentResponse;
|
||||
onSubmit: (data: DocumentFormData, file?: File) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const documentTypes: { value: DocumentType; label: string }[] = [
|
||||
{ value: "general", label: "General" },
|
||||
{ value: "warranty", label: "Warranty" },
|
||||
{ value: "receipt", label: "Receipt" },
|
||||
{ value: "contract", label: "Contract" },
|
||||
{ value: "insurance", label: "Insurance" },
|
||||
{ value: "manual", label: "Manual" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function DocumentForm({
|
||||
document,
|
||||
onSubmit,
|
||||
loading = false,
|
||||
}: DocumentFormProps) {
|
||||
const { data: residences } = useResidences();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const residenceItems = (residences ?? []).map((r) => ({
|
||||
id: r.residence.id,
|
||||
name: r.residence.name,
|
||||
}));
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<DocumentFormData>({
|
||||
resolver: zodResolver(documentSchema),
|
||||
defaultValues: {
|
||||
title: document?.title ?? "",
|
||||
residence_id: document?.residence_id ?? undefined,
|
||||
description: document?.description ?? "",
|
||||
document_type: document?.document_type ?? undefined,
|
||||
vendor: document?.vendor ?? "",
|
||||
serial_number: document?.serial_number ?? "",
|
||||
model_number: document?.model_number ?? "",
|
||||
purchase_date: document?.purchase_date ?? "",
|
||||
expiry_date: document?.expiry_date ?? "",
|
||||
purchase_price: document?.purchase_price ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const residenceId = watch("residence_id");
|
||||
const documentType = watch("document_type");
|
||||
const purchasePrice = watch("purchase_price");
|
||||
const isWarranty = documentType === "warranty";
|
||||
|
||||
const handleFormSubmit = (data: DocumentFormData) => {
|
||||
onSubmit(data, files[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-8">
|
||||
{/* Title & Residence */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="Title"
|
||||
htmlFor="title"
|
||||
error={errors.title?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="Document title"
|
||||
aria-invalid={!!errors.title}
|
||||
{...register("title")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Residence"
|
||||
htmlFor="residence_id"
|
||||
error={errors.residence_id?.message}
|
||||
required
|
||||
>
|
||||
<LookupSelect
|
||||
items={residenceItems}
|
||||
value={residenceId}
|
||||
onValueChange={(v) => setValue("residence_id", v as number)}
|
||||
placeholder="Select residence..."
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* Document Type */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="Document Type" htmlFor="document_type">
|
||||
<Select
|
||||
value={documentType ?? ""}
|
||||
onValueChange={(v) =>
|
||||
setValue("document_type", (v || undefined) as DocumentType | undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{documentTypes.map((dt) => (
|
||||
<SelectItem key={dt.value} value={dt.value}>
|
||||
{dt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<FormField label="Description" htmlFor="description">
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Notes about this document..."
|
||||
rows={3}
|
||||
{...register("description")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Warranty Fields (conditional) */}
|
||||
{isWarranty && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Warranty Details
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="Vendor" htmlFor="vendor">
|
||||
<Input
|
||||
id="vendor"
|
||||
placeholder="e.g. Samsung"
|
||||
{...register("vendor")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Serial Number" htmlFor="serial_number">
|
||||
<Input
|
||||
id="serial_number"
|
||||
placeholder="SN-12345"
|
||||
{...register("serial_number")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Model Number" htmlFor="model_number">
|
||||
<Input
|
||||
id="model_number"
|
||||
placeholder="ABC-100"
|
||||
{...register("model_number")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Purchase Price" htmlFor="purchase_price">
|
||||
<CurrencyInput
|
||||
id="purchase_price"
|
||||
value={purchasePrice}
|
||||
onChange={(v) => setValue("purchase_price", v)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Purchase Date" htmlFor="purchase_date">
|
||||
<Input
|
||||
id="purchase_date"
|
||||
type="date"
|
||||
{...register("purchase_date")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Expiry Date" htmlFor="expiry_date">
|
||||
<Input
|
||||
id="expiry_date"
|
||||
type="date"
|
||||
{...register("expiry_date")}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Upload (create mode only) */}
|
||||
{!document && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Attachment
|
||||
</h3>
|
||||
<FileUpload
|
||||
accept="*"
|
||||
multiple={false}
|
||||
files={files}
|
||||
onChange={setFiles}
|
||||
label="Upload a document file"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="size-4 mr-2 animate-spin" />}
|
||||
{document ? "Save Changes" : "Create Document"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { DocumentImageResponse } from "@/lib/api/documents";
|
||||
|
||||
interface ImageGalleryProps {
|
||||
images: DocumentImageResponse[];
|
||||
}
|
||||
|
||||
export function ImageGallery({ images }: ImageGalleryProps) {
|
||||
const [selectedImage, setSelectedImage] = useState<DocumentImageResponse | null>(null);
|
||||
|
||||
if (images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{images.map((image) => (
|
||||
<button
|
||||
key={image.id}
|
||||
type="button"
|
||||
className="group relative aspect-square overflow-hidden rounded-lg border bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none"
|
||||
onClick={() => setSelectedImage(image)}
|
||||
>
|
||||
<img
|
||||
src={image.image_url}
|
||||
alt={image.caption || "Document image"}
|
||||
className="size-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
{image.caption && (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1">
|
||||
<p className="text-xs text-white truncate">{image.caption}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!selectedImage} onOpenChange={() => setSelectedImage(null)}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedImage?.caption || "Image"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedImage && (
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={selectedImage.image_url}
|
||||
alt={selectedImage.caption || "Document image"}
|
||||
className="max-h-[70vh] w-auto rounded-md object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { differenceInDays } from "date-fns";
|
||||
|
||||
interface WarrantyStatusProps {
|
||||
expiry_date?: string;
|
||||
}
|
||||
|
||||
export function WarrantyStatus({ expiry_date }: WarrantyStatusProps) {
|
||||
if (!expiry_date) {
|
||||
return <Badge variant="secondary">No expiry</Badge>;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const expiry = new Date(expiry_date);
|
||||
expiry.setHours(0, 0, 0, 0);
|
||||
|
||||
const daysRemaining = differenceInDays(expiry, today);
|
||||
|
||||
if (daysRemaining < 0) {
|
||||
return (
|
||||
<Badge variant="destructive">
|
||||
Expired {Math.abs(daysRemaining)} {Math.abs(daysRemaining) === 1 ? "day" : "days"} ago
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (daysRemaining <= 30) {
|
||||
return (
|
||||
<Badge className="bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-900/30 dark:text-yellow-400 dark:border-yellow-700">
|
||||
Expiring soon ({daysRemaining} {daysRemaining === 1 ? "day" : "days"})
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-400 dark:border-green-700">
|
||||
Active ({daysRemaining} {daysRemaining === 1 ? "day" : "days"} left)
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
interface AuthFormWrapperProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthFormWrapper({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
}: AuthFormWrapperProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Casera</h1>
|
||||
</div>
|
||||
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
{subtitle && <CardDescription>{subtitle}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
|
||||
{footer && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CodeInputProps {
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CodeInput({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
}: CodeInputProps) {
|
||||
const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);
|
||||
const digits = value.padEnd(6, "").slice(0, 6).split("");
|
||||
|
||||
function updateCode(newDigits: string[]) {
|
||||
onChange(newDigits.join(""));
|
||||
}
|
||||
|
||||
function handleChange(index: number, char: string) {
|
||||
// Accept only single digits
|
||||
if (char && !/^\d$/.test(char)) return;
|
||||
|
||||
const next = [...digits];
|
||||
next[index] = char;
|
||||
updateCode(next);
|
||||
|
||||
// Auto-advance to next input
|
||||
if (char && index < 5) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(
|
||||
index: number,
|
||||
e: React.KeyboardEvent<HTMLInputElement>
|
||||
) {
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
if (digits[index]) {
|
||||
// Clear current digit
|
||||
const next = [...digits];
|
||||
next[index] = "";
|
||||
updateCode(next);
|
||||
} else if (index > 0) {
|
||||
// Move to previous and clear it
|
||||
const next = [...digits];
|
||||
next[index - 1] = "";
|
||||
updateCode(next);
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
} else if (e.key === "ArrowLeft" && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
} else if (e.key === "ArrowRight" && index < 5) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: React.ClipboardEvent) {
|
||||
e.preventDefault();
|
||||
const pasted = e.clipboardData
|
||||
.getData("text")
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, 6);
|
||||
if (!pasted) return;
|
||||
|
||||
const next = [...digits];
|
||||
for (let i = 0; i < pasted.length && i < 6; i++) {
|
||||
next[i] = pasted[i];
|
||||
}
|
||||
updateCode(next);
|
||||
|
||||
// Focus the input after the last pasted digit
|
||||
const focusIndex = Math.min(pasted.length, 5);
|
||||
inputRefs.current[focusIndex]?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-2 justify-center", className)}>
|
||||
{digits.map((digit, i) => (
|
||||
<Input
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
inputRefs.current[i] = el;
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
disabled={disabled}
|
||||
className="h-12 w-12 text-center text-lg font-semibold"
|
||||
onChange={(e) => handleChange(i, e.target.value.slice(-1))}
|
||||
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||
onPaste={handlePaste}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PasswordInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentProps<"input">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={ref}
|
||||
type={visible ? "text" : "password"}
|
||||
className={cn("pr-10", className)}
|
||||
{...props}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
tabIndex={-1}
|
||||
aria-label={visible ? "Hide password" : "Show password"}
|
||||
>
|
||||
{visible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PasswordInput.displayName = "PasswordInput";
|
||||
|
||||
export { PasswordInput };
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { navItems } from './nav-items';
|
||||
|
||||
// Show the first 5 nav items on mobile (exclude Settings)
|
||||
const mobileNavItems = navItems.filter((item) => item.label !== 'Settings');
|
||||
|
||||
export function MobileNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-card border-t border-border">
|
||||
<div className="flex items-center justify-around px-2 py-2">
|
||||
{mobileNavItems.map((item) => {
|
||||
const isActive =
|
||||
item.href === '/app'
|
||||
? pathname === '/app'
|
||||
: pathname.startsWith(item.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors',
|
||||
isActive
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-5" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Home, Building2, CheckSquare, HardHat, FileText, Settings } from 'lucide-react';
|
||||
|
||||
export interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export const navItems: NavItem[] = [
|
||||
{ label: 'Home', href: '/app', icon: Home },
|
||||
{ label: 'Residences', href: '/app/residences', icon: Building2 },
|
||||
{ label: 'Tasks', href: '/app/tasks', icon: CheckSquare },
|
||||
{ label: 'Contractors', href: '/app/contractors', icon: HardHat },
|
||||
{ label: 'Documents', href: '/app/documents', icon: FileText },
|
||||
{ label: 'Settings', href: '/app/settings', icon: Settings },
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { navItems } from './nav-items';
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="hidden md:flex md:flex-col md:fixed md:inset-y-0 md:left-0 md:z-30 w-16 lg:w-64 bg-card border-r border-border">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center h-16 px-4 lg:px-6">
|
||||
<Link href="/app" className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold text-primary">C</span>
|
||||
<span className="hidden lg:inline text-xl font-bold text-foreground">
|
||||
Casera
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 flex flex-col gap-1 p-2 lg:p-3">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
item.href === '/app'
|
||||
? pathname === '/app'
|
||||
: pathname.startsWith(item.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-5 shrink-0" />
|
||||
<span className="hidden lg:inline">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LogOut, Settings, User } from 'lucide-react';
|
||||
import { NotificationBell } from '@/components/notifications/notification-bell';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
export function TopBar() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
} catch {
|
||||
// Continue with redirect even if the API call fails
|
||||
}
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex items-center justify-between h-16 px-4 lg:px-6 bg-card border-b border-border">
|
||||
{/* Mobile logo - hidden on desktop since sidebar has it */}
|
||||
<div className="md:hidden">
|
||||
<span className="text-xl font-bold text-foreground">Casera</span>
|
||||
</div>
|
||||
|
||||
{/* Spacer for desktop (logo is in sidebar) */}
|
||||
<div className="hidden md:block" />
|
||||
|
||||
{/* Notifications + Profile */}
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||
<Avatar>
|
||||
<AvatarFallback>U</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => router.push('/app/settings')}>
|
||||
<User className="size-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push('/app/settings')}>
|
||||
<Settings className="size-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} variant="destructive">
|
||||
<LogOut className="size-4" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Bell } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useNotifications, useUnreadCount, useMarkAsRead, useMarkAllAsRead } from "@/lib/hooks/use-notifications";
|
||||
|
||||
export function NotificationBell() {
|
||||
const { data: unreadData } = useUnreadCount();
|
||||
const { data: notifData } = useNotifications(10);
|
||||
const markAsRead = useMarkAsRead();
|
||||
const markAllAsRead = useMarkAllAsRead();
|
||||
const unreadCount = unreadData?.unread_count ?? 0;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell className="size-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 max-h-96 overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<p className="text-sm font-semibold">Notifications</p>
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="ghost" size="sm" className="text-xs h-auto py-1" onClick={() => markAllAsRead.mutate()}>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{(!notifData || notifData.results.length === 0) ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">No notifications</div>
|
||||
) : (
|
||||
notifData.results.map((n) => (
|
||||
<DropdownMenuItem key={n.id} className="flex-col items-start gap-1 py-2"
|
||||
onClick={() => { if (!n.is_read) markAsRead.mutate(n.id); }}>
|
||||
<p className={`text-sm ${n.is_read ? "text-muted-foreground" : "font-medium"}`}>{n.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{n.body}</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(n.created_at).toLocaleDateString()}</p>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { Home, Users } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useOnboardingStore } from "@/stores/onboarding";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PathCardProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function PathCard({ icon, title, description, onClick }: PathCardProps) {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"cursor-pointer transition-all hover:border-primary hover:shadow-md"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="flex flex-col items-center text-center space-y-3 py-8">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChoosePathStep() {
|
||||
const { setPath, prevStep } = useOnboardingStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
How would you like to start?
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
You can always do both later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<PathCard
|
||||
icon={<Home className="size-7" />}
|
||||
title="Create a residence"
|
||||
description="Set up your first property to start tracking maintenance."
|
||||
onClick={() => setPath("create")}
|
||||
/>
|
||||
<PathCard
|
||||
icon={<Users className="size-7" />}
|
||||
title="Join a residence"
|
||||
description="Join an existing property with a share code from the owner."
|
||||
onClick={() => setPath("join")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button variant="ghost" onClick={prevStep}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CheckCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useOnboardingStore } from "@/stores/onboarding";
|
||||
|
||||
export function CompleteStep() {
|
||||
const router = useRouter();
|
||||
const { path, residenceId, complete } = useOnboardingStore();
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("onboarding_complete", "true");
|
||||
complete();
|
||||
}, [complete]);
|
||||
|
||||
const isCreatePath = path === "create";
|
||||
|
||||
const handleNavigate = () => {
|
||||
if (isCreatePath && residenceId) {
|
||||
router.push(`/app/residences/${residenceId}`);
|
||||
} else {
|
||||
router.push("/app/residences");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center space-y-6 py-12">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400">
|
||||
<CheckCircle className="size-9" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
{isCreatePath ? "You're all set!" : "Welcome to the residence!"}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg max-w-sm mx-auto">
|
||||
{isCreatePath
|
||||
? "Your residence is ready. Start managing your property like a pro."
|
||||
: "You've successfully joined the residence. Time to get organized."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button size="lg" onClick={handleNavigate} className="mt-4">
|
||||
{isCreatePath ? "Go to your residence" : "View residences"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FormField } from "@/components/shared/form-field";
|
||||
import { useCreateResidence } from "@/lib/hooks/use-residences";
|
||||
import { useOnboardingStore } from "@/stores/onboarding";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema (simplified for onboarding)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createResidenceSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
street_address: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
});
|
||||
|
||||
type CreateResidenceFormData = z.infer<typeof createResidenceSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CreateResidenceStep() {
|
||||
const { nextStep, prevStep, setResidenceId } = useOnboardingStore();
|
||||
const createResidence = useCreateResidence();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<CreateResidenceFormData>({
|
||||
resolver: zodResolver(createResidenceSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
street_address: "",
|
||||
city: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: CreateResidenceFormData) => {
|
||||
createResidence.mutate(
|
||||
{
|
||||
name: data.name,
|
||||
street_address: data.street_address || undefined,
|
||||
city: data.city || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (residence) => {
|
||||
setResidenceId(residence.id);
|
||||
nextStep();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Create your residence
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
You can add more details later. Just give it a name to get started.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
label="Name"
|
||||
htmlFor="name"
|
||||
error={errors.name?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="My Home"
|
||||
aria-invalid={!!errors.name}
|
||||
{...register("name")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Street Address" htmlFor="street_address">
|
||||
<Input
|
||||
id="street_address"
|
||||
placeholder="123 Main St"
|
||||
{...register("street_address")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="City" htmlFor="city">
|
||||
<Input id="city" placeholder="Austin" {...register("city")} />
|
||||
</FormField>
|
||||
|
||||
{createResidence.error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{createResidence.error instanceof Error
|
||||
? createResidence.error.message
|
||||
: "Failed to create residence. Please try again."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button type="button" variant="ghost" onClick={prevStep}>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="submit" disabled={createResidence.isPending}>
|
||||
{createResidence.isPending && (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
)}
|
||||
Create Residence
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { format } from "date-fns";
|
||||
import { CalendarIcon, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FormField } from "@/components/shared/form-field";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useCreateTask } from "@/lib/hooks/use-tasks";
|
||||
import { useOnboardingStore } from "@/stores/onboarding";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const firstTaskSchema = z.object({
|
||||
title: z.string().min(1, "Task title is required"),
|
||||
});
|
||||
|
||||
type FirstTaskFormData = z.infer<typeof firstTaskSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function FirstTaskStep() {
|
||||
const { nextStep, prevStep, residenceId } = useOnboardingStore();
|
||||
const createTask = useCreateTask();
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FirstTaskFormData>({
|
||||
resolver: zodResolver(firstTaskSchema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: FirstTaskFormData) => {
|
||||
if (!residenceId) return;
|
||||
|
||||
createTask.mutate(
|
||||
{
|
||||
residence_id: residenceId,
|
||||
title: data.title,
|
||||
due_date: dueDate ? format(dueDate, "yyyy-MM-dd") : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
nextStep();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
nextStep();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Add your first task
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Create a maintenance task to keep your property in shape. You can skip
|
||||
this for now.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
label="Task Title"
|
||||
htmlFor="title"
|
||||
error={errors.title?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="e.g., Change HVAC filter"
|
||||
aria-invalid={!!errors.title}
|
||||
{...register("title")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Due Date" htmlFor="due_date">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="due_date"
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!dueDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 size-4" />
|
||||
{dueDate ? format(dueDate, "PPP") : "Pick a date (optional)"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dueDate}
|
||||
onSelect={setDueDate}
|
||||
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormField>
|
||||
|
||||
{createTask.error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{createTask.error instanceof Error
|
||||
? createTask.error.message
|
||||
: "Failed to create task. Please try again."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button type="button" variant="ghost" onClick={prevStep}>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleSkip}>
|
||||
Skip
|
||||
</Button>
|
||||
<Button type="submit" disabled={createTask.isPending}>
|
||||
{createTask.isPending && (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
)}
|
||||
Create Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FormField } from "@/components/shared/form-field";
|
||||
import { useJoinResidence } from "@/lib/hooks/use-sharing";
|
||||
import { useOnboardingStore } from "@/stores/onboarding";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const joinResidenceSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.length(6, "Share code must be 6 characters")
|
||||
.regex(/^[A-Za-z0-9]+$/, "Share code must be alphanumeric"),
|
||||
});
|
||||
|
||||
type JoinResidenceFormData = z.infer<typeof joinResidenceSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function JoinResidenceStep() {
|
||||
const { nextStep, prevStep, setResidenceId } = useOnboardingStore();
|
||||
const joinResidence = useJoinResidence();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<JoinResidenceFormData>({
|
||||
resolver: zodResolver(joinResidenceSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: JoinResidenceFormData) => {
|
||||
joinResidence.mutate(data.code, {
|
||||
onSuccess: (residence) => {
|
||||
setResidenceId(residence.id);
|
||||
nextStep();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Join a residence
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Enter the 6-character share code you received from the property owner.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
label="Share Code"
|
||||
htmlFor="code"
|
||||
error={errors.code?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id="code"
|
||||
placeholder="ABC123"
|
||||
maxLength={6}
|
||||
className="text-center text-lg tracking-widest uppercase"
|
||||
aria-invalid={!!errors.code}
|
||||
{...register("code")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{joinResidence.error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{joinResidence.error instanceof Error
|
||||
? joinResidence.error.message
|
||||
: "Invalid or expired share code. Please check and try again."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button type="button" variant="ghost" onClick={prevStep}>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="submit" disabled={joinResidence.isPending}>
|
||||
{joinResidence.isPending && (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
)}
|
||||
Join Residence
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useOnboardingStore } from "@/stores/onboarding";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export function WelcomeStep() {
|
||||
const nextStep = useOnboardingStore((s) => s.nextStep);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const greeting = user?.first_name
|
||||
? `Welcome to Casera, ${user.first_name}!`
|
||||
: "Welcome to Casera!";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center space-y-6 py-12">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight">{greeting}</h2>
|
||||
<p className="text-muted-foreground text-lg max-w-sm mx-auto">
|
||||
Your property management companion. Let's get you set up in just
|
||||
a few steps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button size="lg" onClick={nextStep} className="mt-4">
|
||||
Get Started
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Link from "next/link";
|
||||
import { MapPin } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { MyResidenceResponse } from "@/lib/api/residences";
|
||||
|
||||
interface ResidenceCardProps {
|
||||
data: MyResidenceResponse;
|
||||
}
|
||||
|
||||
export function ResidenceCard({ data }: ResidenceCardProps) {
|
||||
const { residence, task_summary } = data;
|
||||
|
||||
const address = [residence.street_address, residence.city, residence.state_province]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<Link href={`/app/residences/${residence.id}`} className="block">
|
||||
<Card className="transition-colors hover:border-primary/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{residence.name}</CardTitle>
|
||||
{address && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<MapPin className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{address}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{task_summary.overdue > 0 && (
|
||||
<Badge variant="destructive">
|
||||
{task_summary.overdue} overdue
|
||||
</Badge>
|
||||
)}
|
||||
{task_summary.due_soon > 0 && (
|
||||
<Badge variant="secondary">
|
||||
{task_summary.due_soon} due soon
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline">
|
||||
{task_summary.total} {task_summary.total === 1 ? "task" : "tasks"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FormField } from "@/components/shared/form-field";
|
||||
import { LookupSelect } from "@/components/shared/lookup-select";
|
||||
import { useResidenceTypes } from "@/lib/hooks/use-lookups";
|
||||
import type { ResidenceResponse } from "@/lib/api/residences";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const residenceSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
property_type_id: z.number().optional(),
|
||||
street_address: z.string().optional(),
|
||||
apartment_unit: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
state_province: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
bedrooms: z.number().optional(),
|
||||
bathrooms: z.number().optional(),
|
||||
square_footage: z.number().optional(),
|
||||
year_built: z.number().min(1800).max(2100).optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
type ResidenceFormData = z.infer<typeof residenceSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ResidenceFormProps {
|
||||
residence?: ResidenceResponse;
|
||||
onSubmit: (data: ResidenceFormData) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ResidenceForm({ residence, onSubmit, loading = false }: ResidenceFormProps) {
|
||||
const { data: residenceTypes } = useResidenceTypes();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<ResidenceFormData>({
|
||||
resolver: zodResolver(residenceSchema),
|
||||
defaultValues: {
|
||||
name: residence?.name ?? "",
|
||||
property_type_id: residence?.property_type_id ?? undefined,
|
||||
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 ?? undefined,
|
||||
bathrooms: residence?.bathrooms ?? undefined,
|
||||
square_footage: residence?.square_footage ?? undefined,
|
||||
year_built: residence?.year_built ?? undefined,
|
||||
description: residence?.description ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const propertyTypeId = watch("property_type_id");
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
{/* Name & Property Type */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="Name" htmlFor="name" error={errors.name?.message} required>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="My Home"
|
||||
aria-invalid={!!errors.name}
|
||||
{...register("name")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Property Type" htmlFor="property_type_id">
|
||||
<LookupSelect
|
||||
items={residenceTypes}
|
||||
value={propertyTypeId}
|
||||
onValueChange={(v) => setValue("property_type_id", v)}
|
||||
placeholder="Select type..."
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Address</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="Street Address" htmlFor="street_address">
|
||||
<Input
|
||||
id="street_address"
|
||||
placeholder="123 Main St"
|
||||
{...register("street_address")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Apartment / Unit" htmlFor="apartment_unit">
|
||||
<Input
|
||||
id="apartment_unit"
|
||||
placeholder="Apt 4B"
|
||||
{...register("apartment_unit")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="City" htmlFor="city">
|
||||
<Input id="city" placeholder="Austin" {...register("city")} />
|
||||
</FormField>
|
||||
|
||||
<FormField label="State / Province" htmlFor="state_province">
|
||||
<Input
|
||||
id="state_province"
|
||||
placeholder="TX"
|
||||
{...register("state_province")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Postal Code" htmlFor="postal_code">
|
||||
<Input
|
||||
id="postal_code"
|
||||
placeholder="78701"
|
||||
{...register("postal_code")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Country" htmlFor="country">
|
||||
<Input
|
||||
id="country"
|
||||
placeholder="US"
|
||||
{...register("country")}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Property Details */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Property Details</h3>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<FormField label="Bedrooms" htmlFor="bedrooms">
|
||||
<Input
|
||||
id="bedrooms"
|
||||
type="number"
|
||||
placeholder="3"
|
||||
{...register("bedrooms", { valueAsNumber: true })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Bathrooms" htmlFor="bathrooms">
|
||||
<Input
|
||||
id="bathrooms"
|
||||
type="number"
|
||||
placeholder="2"
|
||||
{...register("bathrooms", { valueAsNumber: true })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Sq. Footage" htmlFor="square_footage">
|
||||
<Input
|
||||
id="square_footage"
|
||||
type="number"
|
||||
placeholder="1500"
|
||||
{...register("square_footage", { valueAsNumber: true })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Year Built"
|
||||
htmlFor="year_built"
|
||||
error={errors.year_built?.message}
|
||||
>
|
||||
<Input
|
||||
id="year_built"
|
||||
type="number"
|
||||
placeholder="2000"
|
||||
aria-invalid={!!errors.year_built}
|
||||
{...register("year_built", { valueAsNumber: true })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<FormField label="Description" htmlFor="description">
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Notes about this property..."
|
||||
rows={3}
|
||||
{...register("description")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="size-4 mr-2 animate-spin" />}
|
||||
{residence ? "Save Changes" : "Create Residence"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ClipboardList, Wrench, Users } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface ResidenceSummaryProps {
|
||||
totalTasks: number;
|
||||
inProgress: number;
|
||||
userCount: number;
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value }: StatCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4">
|
||||
<div className="rounded-full bg-muted p-2.5">
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResidenceSummary({ totalTasks, inProgress, userCount }: ResidenceSummaryProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatCard icon={ClipboardList} label="Total Tasks" value={totalTasks} />
|
||||
<StatCard icon={Wrench} label="In Progress" value={inProgress} />
|
||||
<StatCard icon={Users} label="Users" value={userCount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Loader2, Check } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { FormField } from "@/components/shared/form-field";
|
||||
import { PasswordInput } from "@/components/forms/password-input";
|
||||
import * as authApi from "@/lib/api/auth";
|
||||
|
||||
const changePasswordSchema = z
|
||||
.object({
|
||||
current_password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
new_password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirm_password: z.string(),
|
||||
})
|
||||
.refine((data) => data.new_password === data.confirm_password, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirm_password"],
|
||||
});
|
||||
|
||||
type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
|
||||
|
||||
export function ChangePasswordForm() {
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ChangePasswordFormData>({
|
||||
resolver: zodResolver(changePasswordSchema),
|
||||
});
|
||||
|
||||
async function onSubmit(data: ChangePasswordFormData) {
|
||||
setSuccess(false);
|
||||
setApiError(null);
|
||||
try {
|
||||
await authApi.changePassword({
|
||||
current_password: data.current_password,
|
||||
new_password: data.new_password,
|
||||
});
|
||||
reset();
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to change password.";
|
||||
setApiError(message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Change Password</CardTitle>
|
||||
<CardDescription>Update your password to keep your account secure.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{apiError && (
|
||||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
|
||||
<Check className="size-4" />
|
||||
Password changed successfully.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField label="Current password" htmlFor="current_password" error={errors.current_password?.message} required>
|
||||
<PasswordInput
|
||||
id="current_password"
|
||||
autoComplete="current-password"
|
||||
aria-invalid={!!errors.current_password}
|
||||
{...register("current_password")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="New password" htmlFor="new_password" error={errors.new_password?.message} required>
|
||||
<PasswordInput
|
||||
id="new_password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={!!errors.new_password}
|
||||
{...register("new_password")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Confirm new password" htmlFor="confirm_password" error={errors.confirm_password?.message} required>
|
||||
<PasswordInput
|
||||
id="confirm_password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={!!errors.confirm_password}
|
||||
{...register("confirm_password")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="animate-spin" />}
|
||||
Update Password
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import * as authApi from "@/lib/api/auth";
|
||||
|
||||
export function DeleteAccountSection() {
|
||||
const { logout } = useAuthStore();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const isConfirmed = confirmText === "DELETE";
|
||||
|
||||
async function handleDelete() {
|
||||
setApiError(null);
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await authApi.deleteAccount();
|
||||
await logout();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to delete account.";
|
||||
setApiError(message);
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
setConfirmText("");
|
||||
setApiError(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Permanently delete your account and all associated data. This action cannot be undone.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="destructive" onClick={() => setDialogOpen(true)}>
|
||||
Delete Account
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-destructive" />
|
||||
Delete Account
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete your account, all your residences, tasks,
|
||||
documents, and associated data. This action is irreversible.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{apiError && (
|
||||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-delete">
|
||||
Type <span className="font-mono font-semibold">DELETE</span> to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-delete"
|
||||
placeholder="DELETE"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={!isConfirmed || isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete My Account"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useFeatureBenefits } from "@/lib/hooks/use-subscription";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Check, X } from "lucide-react";
|
||||
|
||||
export function FeatureComparison() {
|
||||
const { data: features, isLoading } = useFeatureBenefits();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plan Comparison</CardTitle>
|
||||
<CardDescription>See what each plan includes.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="flex gap-8">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!features || features.length === 0) return null;
|
||||
|
||||
// Sort by sort_order
|
||||
const sortedFeatures = [...features].sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plan Comparison</CardTitle>
|
||||
<CardDescription>See what each plan includes.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Table header */}
|
||||
<div className="flex items-center justify-between border-b pb-3 mb-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">Feature</span>
|
||||
<div className="flex gap-8 text-sm font-medium text-muted-foreground">
|
||||
<span className="w-12 text-center">Free</span>
|
||||
<span className="w-12 text-center">Premium</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature rows */}
|
||||
<div className="space-y-3">
|
||||
{sortedFeatures.map((feature) => {
|
||||
const isFreeFeature = feature.tier === "free";
|
||||
return (
|
||||
<div key={feature.id} className="flex items-center justify-between py-1">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">{feature.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{feature.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-8 shrink-0">
|
||||
<div className="w-12 flex justify-center">
|
||||
{isFreeFeature ? (
|
||||
<Check className="size-4 text-green-500" />
|
||||
) : (
|
||||
<X className="size-4 text-muted-foreground/40" />
|
||||
)}
|
||||
</div>
|
||||
<div className="w-12 flex justify-center">
|
||||
<Check className="size-4 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useNotificationPreferences, useUpdatePreferences } from "@/lib/hooks/use-notifications";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { NotificationPreferencesResponse, UpdatePreferencesRequest } from "@/lib/api/notifications";
|
||||
|
||||
const preferenceItems: {
|
||||
key: keyof NotificationPreferencesResponse;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "task_reminders",
|
||||
label: "Task Reminders",
|
||||
description: "Get notified about upcoming and overdue tasks",
|
||||
},
|
||||
{
|
||||
key: "task_completions",
|
||||
label: "Task Completions",
|
||||
description: "Get notified when tasks are completed",
|
||||
},
|
||||
{
|
||||
key: "residence_updates",
|
||||
label: "Residence Updates",
|
||||
description: "Get notified about residence changes",
|
||||
},
|
||||
{
|
||||
key: "share_notifications",
|
||||
label: "Share Notifications",
|
||||
description: "Get notified about sharing activity",
|
||||
},
|
||||
{
|
||||
key: "marketing",
|
||||
label: "Marketing",
|
||||
description: "Receive product updates and tips",
|
||||
},
|
||||
];
|
||||
|
||||
export function NotificationPreferences() {
|
||||
const { data: preferences, isLoading } = useNotificationPreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
|
||||
function handleToggle(key: keyof NotificationPreferencesResponse, checked: boolean) {
|
||||
const update: UpdatePreferencesRequest = { [key]: checked };
|
||||
updatePreferences.mutate(update);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Preferences</CardTitle>
|
||||
<CardDescription>Control which notifications you receive.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-56" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-9 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Preferences</CardTitle>
|
||||
<CardDescription>Control which notifications you receive.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{preferenceItems.map((item) => {
|
||||
const isChecked = preferences?.[item.key] ?? false;
|
||||
return (
|
||||
<div key={item.key} className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={item.key} className="text-sm font-medium">
|
||||
{item.label}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={item.key}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => handleToggle(item.key, checked as boolean)}
|
||||
disabled={updatePreferences.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Loader2, Check } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { FormField } from "@/components/shared/form-field";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import * as authApi from "@/lib/api/auth";
|
||||
|
||||
const profileSchema = z.object({
|
||||
first_name: z.string().min(1, "First name is required"),
|
||||
last_name: z.string().min(1, "Last name is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
});
|
||||
|
||||
type ProfileFormData = z.infer<typeof profileSchema>;
|
||||
|
||||
export function ProfileForm() {
|
||||
const { user, fetchUser } = useAuthStore();
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ProfileFormData>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
first_name: user?.first_name ?? "",
|
||||
last_name: user?.last_name ?? "",
|
||||
email: user?.email ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: ProfileFormData) {
|
||||
setSuccess(false);
|
||||
setApiError(null);
|
||||
try {
|
||||
await authApi.updateProfile(data);
|
||||
await fetchUser();
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to update profile.";
|
||||
setApiError(message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Personal Information</CardTitle>
|
||||
<CardDescription>Update your name and email address.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{apiError && (
|
||||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
|
||||
<Check className="size-4" />
|
||||
Profile updated successfully.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField label="First name" htmlFor="first_name" error={errors.first_name?.message} required>
|
||||
<Input
|
||||
id="first_name"
|
||||
autoComplete="given-name"
|
||||
aria-invalid={!!errors.first_name}
|
||||
{...register("first_name")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Last name" htmlFor="last_name" error={errors.last_name?.message} required>
|
||||
<Input
|
||||
id="last_name"
|
||||
autoComplete="family-name"
|
||||
aria-invalid={!!errors.last_name}
|
||||
{...register("last_name")}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Email" htmlFor="email" error={errors.email?.message} required>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
aria-invalid={!!errors.email}
|
||||
{...register("email")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="animate-spin" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useSubscriptionStatus } from "@/lib/hooks/use-subscription";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Crown, Sparkles } from "lucide-react";
|
||||
|
||||
interface LimitBarProps {
|
||||
label: string;
|
||||
current?: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
function LimitBar({ label, max }: LimitBarProps) {
|
||||
// The API returns limits but not current usage counts. We show the max
|
||||
// allowed value for now. When usage data is available from the API, we can
|
||||
// display a real progress bar.
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium">{max === -1 ? "Unlimited" : max}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: max === -1 ? "100%" : "0%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubscriptionStatus() {
|
||||
const { data: status, isLoading } = useSubscriptionStatus();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Subscription</CardTitle>
|
||||
<CardDescription>Your current plan and usage limits.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
const isFree = status.tier === "free";
|
||||
const isPremium = status.tier === "premium";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Subscription</CardTitle>
|
||||
<CardDescription>Your current plan and usage limits.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Tier badge and status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={isPremium ? "default" : "secondary"} className="gap-1">
|
||||
{isPremium ? <Crown className="size-3" /> : null}
|
||||
{status.tier.charAt(0).toUpperCase() + status.tier.slice(1)}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{status.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expiry date for premium */}
|
||||
{isPremium && status.expires_at && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Renews on{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{new Date(status.expires_at).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Limits */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Plan Limits</h3>
|
||||
<LimitBar label="Residences" max={status.limits.max_residences} />
|
||||
<LimitBar label="Tasks per Residence" max={status.limits.max_tasks_per_residence} />
|
||||
<LimitBar label="Contractors" max={status.limits.max_contractors} />
|
||||
<LimitBar label="Documents" max={status.limits.max_documents} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
className={`size-2 rounded-full ${status.limits.can_share ? "bg-green-500" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
<span className="text-muted-foreground">Sharing</span>
|
||||
<span className="font-medium">{status.limits.can_share ? "Enabled" : "Disabled"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
className={`size-2 rounded-full ${status.limits.can_export ? "bg-green-500" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
<span className="text-muted-foreground">Export</span>
|
||||
<span className="font-medium">{status.limits.can_export ? "Enabled" : "Disabled"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upgrade CTA for free tier */}
|
||||
{isFree && (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="size-5 text-primary mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Upgrade to Premium</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unlock unlimited residences, tasks, and more features.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground pt-1">
|
||||
Available through the Casera iOS or Android app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { themes } from "@/lib/themes/theme-config";
|
||||
import { useThemeStore, type ColorMode } from "@/stores/theme";
|
||||
|
||||
const modeOptions: { value: ColorMode; label: string; icon: React.ElementType }[] = [
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
];
|
||||
|
||||
export function ThemePicker() {
|
||||
const { themeId, mode, setTheme, setMode } = useThemeStore();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<CardDescription>Choose a theme and color mode for the app.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Theme swatches */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">Theme</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{themes.map((theme) => (
|
||||
<button
|
||||
key={theme.id}
|
||||
type="button"
|
||||
onClick={() => setTheme(theme.id)}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-accent",
|
||||
themeId === theme.id && "bg-accent"
|
||||
)}
|
||||
title={theme.name}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-8 rounded-full border-2 transition-all",
|
||||
themeId === theme.id
|
||||
? "border-foreground ring-2 ring-foreground ring-offset-2 ring-offset-background"
|
||||
: "border-transparent"
|
||||
)}
|
||||
style={{ backgroundColor: theme.light.primary }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground group-hover:text-foreground">
|
||||
{theme.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">Mode</p>
|
||||
<div className="inline-flex items-center rounded-lg border bg-muted p-1 gap-1">
|
||||
{modeOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant={mode === opt.value ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setMode(opt.value)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<opt.icon className="size-4" />
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
variant?: "default" | "destructive";
|
||||
loading?: boolean;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ open, onOpenChange, title, description, confirmLabel = "Confirm", variant = "default", loading = false, onConfirm }: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{title}</DialogTitle><DialogDescription>{description}</DialogDescription></DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
|
||||
<Button variant={variant} onClick={onConfirm} disabled={loading}>{loading ? "..." : confirmLabel}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
interface CurrencyInputProps extends Omit<React.ComponentProps<typeof Input>, "value" | "onChange"> {
|
||||
value?: number;
|
||||
onChange: (value: number | undefined) => void;
|
||||
}
|
||||
|
||||
export const CurrencyInput = forwardRef<HTMLInputElement, CurrencyInputProps>(
|
||||
function CurrencyInput({ value, onChange, ...props }, ref) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">$</span>
|
||||
<Input ref={ref} type="number" step="0.01" min="0" className="pl-7"
|
||||
value={value ?? ""} onChange={(e) => { const v = e.target.value; onChange(v === "" ? undefined : Number(v)); }} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LucideIcon, Plus } from "lucide-react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="rounded-full bg-muted p-4 mb-4"><Icon className="size-8 text-muted-foreground" /></div>
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<p className="text-muted-foreground mt-1 max-w-sm">{description}</p>
|
||||
{actionLabel && onAction && (
|
||||
<Button onClick={onAction} className="mt-4"><Plus className="size-4 mr-2" />{actionLabel}</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
interface ErrorBannerProps {
|
||||
message?: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorBanner({ message = "Something went wrong. Please try again.", onRetry }: ErrorBannerProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 flex items-center gap-3">
|
||||
<AlertTriangle className="size-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive flex-1">{message}</p>
|
||||
{onRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Upload, X, FileIcon } from "lucide-react";
|
||||
|
||||
interface FileUploadProps {
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
maxSizeMB?: number;
|
||||
files: File[];
|
||||
onChange: (files: File[]) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function FileUpload({ accept = "image/*", multiple = false, maxSizeMB = 10, files, onChange, label = "Upload files" }: FileUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const handleFiles = useCallback((fileList: FileList) => {
|
||||
setError(undefined);
|
||||
const newFiles = Array.from(fileList);
|
||||
const oversized = newFiles.find((f) => f.size > maxSizeMB * 1024 * 1024);
|
||||
if (oversized) { setError(`File "${oversized.name}" exceeds ${maxSizeMB}MB limit`); return; }
|
||||
onChange(multiple ? [...files, ...newFiles] : newFiles.slice(0, 1));
|
||||
}, [files, maxSizeMB, multiple, onChange]);
|
||||
|
||||
const removeFile = (index: number) => { onChange(files.filter((_, i) => i !== index)); };
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => { e.preventDefault(); if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files); }}>
|
||||
<Upload className="size-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Drag & drop or click to browse (max {maxSizeMB}MB)</p>
|
||||
</div>
|
||||
<input ref={inputRef} type="file" accept={accept} multiple={multiple} className="hidden"
|
||||
onChange={(e) => { if (e.target.files?.length) handleFiles(e.target.files); e.target.value = ""; }} />
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{files.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{files.map((file, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm rounded border p-2">
|
||||
<FileIcon className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate">{file.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{(file.size / 1024).toFixed(0)}KB</span>
|
||||
<Button variant="ghost" size="icon" className="size-6" onClick={(e) => { e.stopPropagation(); removeFile(i); }}>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FormField({ label, htmlFor, error, required, className, children }: FormFieldProps) {
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<Label htmlFor={htmlFor}>{label}{required && <span className="text-destructive ml-1">*</span>}</Label>
|
||||
{children}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface LoadingSkeletonProps {
|
||||
variant: "card-grid" | "list" | "detail" | "kanban";
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export function LoadingSkeleton({ variant, count = 4 }: LoadingSkeletonProps) {
|
||||
if (variant === "card-grid") {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<div className="flex gap-2 pt-2"><Skeleton className="h-6 w-16 rounded-full" /><Skeleton className="h-6 w-16 rounded-full" /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 rounded-lg border p-4">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2"><Skeleton className="h-4 w-1/3" /><Skeleton className="h-3 w-1/2" /></div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (variant === "detail") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-1/3" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2"><Skeleton className="h-3 w-20" /><Skeleton className="h-5 w-full" /></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="min-w-[280px] rounded-lg border p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-24" /><Skeleton className="h-24 w-full rounded-md" /><Skeleton className="h-24 w-full rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface LookupItem { id: number; name: string; icon?: string; }
|
||||
|
||||
interface LookupSelectProps {
|
||||
items: LookupItem[];
|
||||
value?: number;
|
||||
onValueChange: (value: number | undefined) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function LookupSelect({ items, value, onValueChange, placeholder = "Select...", disabled }: LookupSelectProps) {
|
||||
return (
|
||||
<Select value={value != null ? String(value) : undefined} onValueChange={(v) => onValueChange(v ? Number(v) : undefined)} disabled={disabled}>
|
||||
<SelectTrigger><SelectValue placeholder={placeholder} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.id} value={String(item.id)}>
|
||||
{item.icon && <span className="mr-2">{item.icon}</span>}{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, actionLabel, onAction, children }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
|
||||
{description && <p className="text-muted-foreground mt-1">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{children}
|
||||
{actionLabel && onAction && (
|
||||
<Button onClick={onAction}><Plus className="size-4 mr-2" />{actionLabel}</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
import { Star } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StarRatingProps {
|
||||
value: number;
|
||||
onChange?: (value: number) => void;
|
||||
readonly?: boolean;
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
export function StarRating({ value, onChange, readonly = false, size = "md" }: StarRatingProps) {
|
||||
const starSize = size === "sm" ? "size-4" : "size-5";
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button key={star} type="button" disabled={readonly}
|
||||
className={cn("transition-colors", !readonly && "cursor-pointer hover:text-yellow-400")}
|
||||
onClick={() => onChange?.(star === value ? 0 : star)}>
|
||||
<Star className={cn(starSize, star <= value ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground")} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Crown } from "lucide-react";
|
||||
|
||||
interface UpgradePromptProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
feature: string;
|
||||
limitInfo?: string;
|
||||
}
|
||||
|
||||
export function UpgradePrompt({ open, onOpenChange, feature, limitInfo }: UpgradePromptProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="mx-auto mb-2 flex size-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<Crown className="size-6 text-primary" />
|
||||
</div>
|
||||
<DialogTitle className="text-center">Upgrade to Premium</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
{feature}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{limitInfo && (
|
||||
<div className="rounded-lg border bg-muted/50 p-3 text-center">
|
||||
<p className="text-sm text-muted-foreground">{limitInfo}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Premium unlocks unlimited residences, tasks, contractors, documents, sharing, and export
|
||||
features.
|
||||
</p>
|
||||
<p>
|
||||
Subscriptions are managed through the Casera iOS or Android app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-col">
|
||||
<Button className="w-full" onClick={() => onOpenChange(false)}>
|
||||
<Crown className="size-4" />
|
||||
Upgrade on App Store
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full" onClick={() => onOpenChange(false)}>
|
||||
Maybe Later
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Upload, FileDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export helper (non-component function)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generates a `.casera` file from the given data object and triggers a download.
|
||||
*/
|
||||
export function downloadCaseraFile(data: object, filename: string) {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename.endsWith(".casera") ? filename : `${filename}.casera`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CaseraFileImportProps {
|
||||
onImport: (data: unknown) => void;
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
export function CaseraFileImport({ onImport, accept = ".casera" }: CaseraFileImportProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
const processFile = useCallback(
|
||||
(file: File) => {
|
||||
setError(null);
|
||||
setFileName(file.name);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const text = e.target?.result;
|
||||
if (typeof text !== "string") {
|
||||
setError("Failed to read file.");
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(text);
|
||||
onImport(parsed);
|
||||
} catch {
|
||||
setError("Invalid .casera file. Could not parse contents.");
|
||||
setFileName(null);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError("Failed to read file.");
|
||||
setFileName(null);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[onImport],
|
||||
);
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) processFile(file);
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) processFile(file);
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragActive(true);
|
||||
}
|
||||
|
||||
function handleDragLeave(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer ${
|
||||
dragActive
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-muted-foreground/25 hover:border-muted-foreground/50"
|
||||
}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload className="size-8 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">
|
||||
Drop a .casera file here or click to browse
|
||||
</p>
|
||||
{fileName && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Selected: {fileName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export button component (convenience wrapper)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CaseraFileExportProps {
|
||||
data: object;
|
||||
filename: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function CaseraFileExport({ data, filename, label = "Export .casera" }: CaseraFileExportProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => downloadCaseraFile(data, filename)}
|
||||
>
|
||||
<FileDown className="size-4 mr-2" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, Check, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useShareCode, useGenerateShareCode } from "@/lib/hooks/use-sharing";
|
||||
|
||||
interface ShareCodeDisplayProps {
|
||||
residenceId: number;
|
||||
}
|
||||
|
||||
export function ShareCodeDisplay({ residenceId }: ShareCodeDisplayProps) {
|
||||
const { data, isLoading } = useShareCode(residenceId);
|
||||
const generateCode = useGenerateShareCode(residenceId);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const shareCode = data?.share_code;
|
||||
|
||||
async function handleCopy() {
|
||||
if (!shareCode) return;
|
||||
await navigator.clipboard.writeText(shareCode.code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
function handleGenerate() {
|
||||
generateCode.mutate();
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Share Code</CardTitle>
|
||||
<CardDescription>Loading...</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Share Code</CardTitle>
|
||||
<CardDescription>
|
||||
Share this code with others to invite them to your residence.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{shareCode ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<code className="flex-1 rounded-lg border bg-muted px-4 py-3 text-center text-2xl font-mono font-bold tracking-widest">
|
||||
{shareCode.code}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Expires{" "}
|
||||
{new Date(shareCode.expires_at).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={generateCode.isPending}
|
||||
>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
{generateCode.isPending ? "Generating..." : "Generate New Code"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
No active share code. Generate one to invite others.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={generateCode.isPending}
|
||||
>
|
||||
{generateCode.isPending ? "Generating..." : "Generate Share Code"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { UserMinus } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { ErrorBanner } from "@/components/shared/error-banner";
|
||||
import { useResidenceUsers, useRemoveResidenceUser } from "@/lib/hooks/use-sharing";
|
||||
|
||||
interface UserManagementProps {
|
||||
residenceId: number;
|
||||
}
|
||||
|
||||
export function UserManagement({ residenceId }: UserManagementProps) {
|
||||
const { data: users, isLoading, isError, error, refetch } = useResidenceUsers(residenceId);
|
||||
const removeUser = useRemoveResidenceUser(residenceId);
|
||||
|
||||
const [removeTarget, setRemoveTarget] = useState<{ id: number; name: string } | null>(null);
|
||||
|
||||
function handleRemove() {
|
||||
if (!removeTarget) return;
|
||||
removeUser.mutate(removeTarget.id, {
|
||||
onSuccess: () => {
|
||||
setRemoveTarget(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Members</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LoadingSkeleton variant="list" count={3} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Members</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ErrorBanner
|
||||
message={error instanceof Error ? error.message : "Failed to load members."}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Members</CardTitle>
|
||||
<CardDescription>
|
||||
People who have access to this residence.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!users || users.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No members found.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{users.map((user) => {
|
||||
const displayName = [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-muted text-sm font-medium shrink-0">
|
||||
{(user.first_name?.[0] || user.username[0] || "?").toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{displayName}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant={user.is_owner ? "default" : "secondary"}>
|
||||
{user.is_owner ? "Owner" : "Member"}
|
||||
</Badge>
|
||||
{!user.is_owner && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setRemoveTarget({ id: user.id, name: displayName })}
|
||||
title="Remove member"
|
||||
>
|
||||
<UserMinus className="size-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={removeTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRemoveTarget(null);
|
||||
}}
|
||||
title="Remove Member"
|
||||
description={`Are you sure you want to remove ${removeTarget?.name ?? "this member"} from this residence? They will lose access to all shared data.`}
|
||||
confirmLabel="Remove"
|
||||
variant="destructive"
|
||||
loading={removeUser.isPending}
|
||||
onConfirm={handleRemove}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { useMarkInProgress } from "@/lib/hooks/use-tasks";
|
||||
import { KanbanColumn } from "./kanban-column";
|
||||
import type { KanbanResponse } from "@/lib/api/tasks";
|
||||
|
||||
interface KanbanBoardProps {
|
||||
data: KanbanResponse;
|
||||
}
|
||||
|
||||
export function KanbanBoard({ data }: KanbanBoardProps) {
|
||||
const router = useRouter();
|
||||
const markInProgress = useMarkInProgress();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const taskId = active.id as number;
|
||||
const targetColumn = over.id as string;
|
||||
|
||||
if (targetColumn === "in_progress") {
|
||||
markInProgress.mutate(taskId);
|
||||
} else if (targetColumn === "completed") {
|
||||
router.push(`/app/tasks/${taskId}/complete`);
|
||||
}
|
||||
},
|
||||
[markInProgress, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{data.columns.map((column) => (
|
||||
<KanbanColumn key={column.name} column={column} />
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskCard } from "./task-card";
|
||||
import type { KanbanColumn as KanbanColumnType } from "@/lib/api/tasks";
|
||||
|
||||
const COLUMN_COLORS: Record<string, string> = {
|
||||
overdue: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20",
|
||||
due_today: "border-orange-500/50 bg-orange-50/50 dark:bg-orange-950/20",
|
||||
due_soon: "border-yellow-500/50 bg-yellow-50/50 dark:bg-yellow-950/20",
|
||||
upcoming: "border-blue-500/50 bg-blue-50/50 dark:bg-blue-950/20",
|
||||
in_progress: "border-green-500/50 bg-green-50/50 dark:bg-green-950/20",
|
||||
completed: "border-gray-500/50 bg-gray-50/50 dark:bg-gray-950/20",
|
||||
};
|
||||
|
||||
const COLUMN_HEADER_COLORS: Record<string, string> = {
|
||||
overdue: "text-red-700 dark:text-red-400",
|
||||
due_today: "text-orange-700 dark:text-orange-400",
|
||||
due_soon: "text-yellow-700 dark:text-yellow-400",
|
||||
upcoming: "text-blue-700 dark:text-blue-400",
|
||||
in_progress: "text-green-700 dark:text-green-400",
|
||||
completed: "text-gray-700 dark:text-gray-400",
|
||||
};
|
||||
|
||||
const COUNT_BADGE_COLORS: Record<string, string> = {
|
||||
overdue: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
due_today: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
|
||||
due_soon: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
upcoming: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
in_progress: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
completed: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200",
|
||||
};
|
||||
|
||||
interface KanbanColumnProps {
|
||||
column: KanbanColumnType;
|
||||
}
|
||||
|
||||
function SortableTask({ task }: { task: import("@/lib/api/tasks").TaskResponse }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: task.id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<TaskCard task={task} isDragging={isDragging} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanColumn({ column }: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: column.name,
|
||||
});
|
||||
|
||||
const taskIds = column.tasks.map((t) => t.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col min-w-[280px] max-w-[320px] rounded-lg border-2 p-3",
|
||||
COLUMN_COLORS[column.name] ?? "border-border bg-muted/30",
|
||||
isOver && "ring-2 ring-primary"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h3
|
||||
className={cn(
|
||||
"font-semibold text-sm",
|
||||
COLUMN_HEADER_COLORS[column.name]
|
||||
)}
|
||||
>
|
||||
{column.display_name}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("text-xs", COUNT_BADGE_COLORS[column.name])}
|
||||
>
|
||||
{column.count}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div ref={setNodeRef} className="flex-1 space-y-2 min-h-[60px]">
|
||||
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
|
||||
{column.tasks.map((task) => (
|
||||
<SortableTask key={task.id} task={task} />
|
||||
))}
|
||||
</SortableContext>
|
||||
{column.tasks.length === 0 && (
|
||||
<div className="flex items-center justify-center h-[60px] text-xs text-muted-foreground rounded-md border border-dashed">
|
||||
No tasks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import {
|
||||
MoreVertical,
|
||||
CheckCircle,
|
||||
Play,
|
||||
Pencil,
|
||||
XCircle,
|
||||
Archive,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useMarkInProgress,
|
||||
useCancelTask,
|
||||
useArchiveTask,
|
||||
useDeleteTask,
|
||||
} from "@/lib/hooks/use-tasks";
|
||||
|
||||
interface TaskActionsMenuProps {
|
||||
taskId: number;
|
||||
}
|
||||
|
||||
export function TaskActionsMenu({ taskId }: TaskActionsMenuProps) {
|
||||
const router = useRouter();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const markInProgress = useMarkInProgress();
|
||||
const cancelTask = useCancelTask();
|
||||
const archiveTask = useArchiveTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<MoreVertical className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push(`/app/tasks/${taskId}/complete`)}
|
||||
>
|
||||
<CheckCircle className="size-4" />
|
||||
Complete
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => markInProgress.mutate(taskId)}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Mark In Progress
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push(`/app/tasks/${taskId}/edit`)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => cancelTask.mutate(taskId)}>
|
||||
<XCircle className="size-4" />
|
||||
Cancel
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => archiveTask.mutate(taskId)}>
|
||||
<Archive className="size-4" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
title="Delete Task"
|
||||
description="Are you sure you want to delete this task? This action cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
variant="destructive"
|
||||
loading={deleteTask.isPending}
|
||||
onConfirm={() => {
|
||||
deleteTask.mutate(taskId, {
|
||||
onSuccess: () => {
|
||||
setDeleteOpen(false);
|
||||
router.push("/app/tasks");
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Calendar, DollarSign } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskResponse } from "@/lib/api/tasks";
|
||||
|
||||
interface TaskCardProps {
|
||||
task: TaskResponse;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, isDragging }: TaskCardProps) {
|
||||
return (
|
||||
<Link href={`/app/tasks/${task.id}`}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card p-3 space-y-2 transition-shadow hover:shadow-md cursor-grab",
|
||||
isDragging && "shadow-lg ring-2 ring-primary"
|
||||
)}
|
||||
>
|
||||
<div className="font-medium text-sm leading-tight line-clamp-2">
|
||||
{task.title}
|
||||
</div>
|
||||
|
||||
{task.residence_name && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{task.residence_name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{task.priority && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||
{task.priority.icon && (
|
||||
<span className="mr-0.5">{task.priority.icon}</span>
|
||||
)}
|
||||
{task.priority.name}
|
||||
</Badge>
|
||||
)}
|
||||
{task.category && (
|
||||
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
||||
{task.category.icon && (
|
||||
<span className="mr-0.5">{task.category.icon}</span>
|
||||
)}
|
||||
{task.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{task.due_date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="size-3" />
|
||||
{new Date(task.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{task.estimated_cost != null && task.estimated_cost > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="size-3" />
|
||||
{task.estimated_cost.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FormField } from "@/components/shared/form-field";
|
||||
import { CurrencyInput } from "@/components/shared/currency-input";
|
||||
import { StarRating } from "@/components/shared/star-rating";
|
||||
import { FileUpload } from "@/components/shared/file-upload";
|
||||
|
||||
const completionSchema = z.object({
|
||||
completed_at: z.string().optional(),
|
||||
actual_cost: z.number().optional(),
|
||||
notes: z.string().optional(),
|
||||
rating: z.number().min(0).max(5).optional(),
|
||||
});
|
||||
|
||||
type CompletionFormValues = z.infer<typeof completionSchema>;
|
||||
|
||||
interface TaskCompletionFormProps {
|
||||
onSubmit: (data: CompletionFormValues, images: File[]) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
const h = String(date.getHours()).padStart(2, "0");
|
||||
const min = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}T${h}:${min}`;
|
||||
}
|
||||
|
||||
export function TaskCompletionForm({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: TaskCompletionFormProps) {
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<CompletionFormValues>({
|
||||
resolver: zodResolver(completionSchema),
|
||||
defaultValues: {
|
||||
completed_at: toDateTimeLocalValue(new Date()),
|
||||
rating: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const handleFormSubmit = (data: CompletionFormValues) => {
|
||||
onSubmit(data, images);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
label="Completed At"
|
||||
htmlFor="completed_at"
|
||||
error={errors.completed_at?.message}
|
||||
>
|
||||
<Input
|
||||
id="completed_at"
|
||||
type="datetime-local"
|
||||
{...register("completed_at")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Actual Cost"
|
||||
htmlFor="actual_cost"
|
||||
error={errors.actual_cost?.message}
|
||||
>
|
||||
<CurrencyInput
|
||||
value={watch("actual_cost")}
|
||||
onChange={(v) => setValue("actual_cost", v)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Notes" htmlFor="notes" error={errors.notes?.message}>
|
||||
<Textarea
|
||||
id="notes"
|
||||
rows={3}
|
||||
placeholder="Add notes about this completion..."
|
||||
{...register("notes")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Rating"
|
||||
htmlFor="rating"
|
||||
error={errors.rating?.message}
|
||||
>
|
||||
<StarRating
|
||||
value={watch("rating") ?? 0}
|
||||
onChange={(v) => setValue("rating", v)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Photos" htmlFor="photos">
|
||||
<FileUpload
|
||||
accept="image/*"
|
||||
multiple
|
||||
files={images}
|
||||
onChange={setImages}
|
||||
label="Upload completion photos"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Complete Task"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FormField } from "@/components/shared/form-field";
|
||||
import { LookupSelect } from "@/components/shared/lookup-select";
|
||||
import { CurrencyInput } from "@/components/shared/currency-input";
|
||||
import { TemplateSearch } from "./template-search";
|
||||
import { useResidences } from "@/lib/hooks/use-residences";
|
||||
import { useContractors } from "@/lib/hooks/use-contractors";
|
||||
import {
|
||||
useTaskCategories,
|
||||
useTaskPriorities,
|
||||
useTaskFrequencies,
|
||||
} from "@/lib/hooks/use-lookups";
|
||||
import type { TaskResponse } from "@/lib/api/tasks";
|
||||
import type { TaskTemplateResponse } from "@/lib/api/lookups";
|
||||
|
||||
const taskSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
residence_id: z.number({ error: "Residence is required" }),
|
||||
description: z.string().optional(),
|
||||
category_id: z.number().optional(),
|
||||
priority_id: z.number().optional(),
|
||||
frequency_id: z.number().optional(),
|
||||
due_date: z.string().optional(),
|
||||
estimated_cost: z.number().optional(),
|
||||
contractor_id: z.number().optional(),
|
||||
});
|
||||
|
||||
type TaskFormValues = z.infer<typeof taskSchema>;
|
||||
|
||||
interface TaskFormProps {
|
||||
task?: TaskResponse;
|
||||
onSubmit: (data: TaskFormValues) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
export function TaskForm({ task, onSubmit, isSubmitting }: TaskFormProps) {
|
||||
const isEdit = !!task;
|
||||
|
||||
const { data: residences } = useResidences();
|
||||
const { data: contractors } = useContractors();
|
||||
const { data: categories } = useTaskCategories();
|
||||
const { data: priorities } = useTaskPriorities();
|
||||
const { data: frequencies } = useTaskFrequencies();
|
||||
|
||||
const residenceItems = (residences ?? []).map((r) => ({
|
||||
id: r.residence.id,
|
||||
name: r.residence.name,
|
||||
}));
|
||||
|
||||
const contractorItems = (contractors ?? []).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.company ? `${c.name} (${c.company})` : c.name,
|
||||
}));
|
||||
|
||||
const categoryItems = categories.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
icon: c.icon,
|
||||
}));
|
||||
|
||||
const priorityItems = priorities.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
icon: p.icon,
|
||||
}));
|
||||
|
||||
const frequencyItems = frequencies.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
}));
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<TaskFormValues>({
|
||||
resolver: zodResolver(taskSchema),
|
||||
defaultValues: {
|
||||
title: task?.title ?? "",
|
||||
residence_id: task?.residence_id,
|
||||
description: task?.description ?? "",
|
||||
category_id: task?.category_id,
|
||||
priority_id: task?.priority_id,
|
||||
frequency_id: task?.frequency_id,
|
||||
due_date: task?.due_date ? task.due_date.split("T")[0] : undefined,
|
||||
estimated_cost: task?.estimated_cost,
|
||||
contractor_id: task?.contractor_id,
|
||||
},
|
||||
});
|
||||
|
||||
const handleTemplateSelect = (template: TaskTemplateResponse) => {
|
||||
if (template.category_id) setValue("category_id", template.category_id);
|
||||
if (template.priority_id) setValue("priority_id", template.priority_id);
|
||||
if (template.frequency_id) setValue("frequency_id", template.frequency_id);
|
||||
if (template.estimated_cost) setValue("estimated_cost", template.estimated_cost);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
label="Title"
|
||||
htmlFor="title"
|
||||
error={errors.title?.message}
|
||||
required
|
||||
>
|
||||
{isEdit ? (
|
||||
<Input id="title" {...register("title")} />
|
||||
) : (
|
||||
<TemplateSearch
|
||||
onTitleChange={(value) => setValue("title", value, { shouldValidate: true })}
|
||||
onSelect={handleTemplateSelect}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Residence"
|
||||
htmlFor="residence_id"
|
||||
error={errors.residence_id?.message}
|
||||
required
|
||||
>
|
||||
<LookupSelect
|
||||
items={residenceItems}
|
||||
value={watch("residence_id")}
|
||||
onValueChange={(v) => setValue("residence_id", v as number, { shouldValidate: true })}
|
||||
placeholder="Select residence..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description" htmlFor="description">
|
||||
<Textarea id="description" rows={3} {...register("description")} />
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField label="Category" htmlFor="category_id">
|
||||
<LookupSelect
|
||||
items={categoryItems}
|
||||
value={watch("category_id")}
|
||||
onValueChange={(v) => setValue("category_id", v)}
|
||||
placeholder="Select category..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Priority" htmlFor="priority_id">
|
||||
<LookupSelect
|
||||
items={priorityItems}
|
||||
value={watch("priority_id")}
|
||||
onValueChange={(v) => setValue("priority_id", v)}
|
||||
placeholder="Select priority..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Frequency" htmlFor="frequency_id">
|
||||
<LookupSelect
|
||||
items={frequencyItems}
|
||||
value={watch("frequency_id")}
|
||||
onValueChange={(v) => setValue("frequency_id", v)}
|
||||
placeholder="Select frequency..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Due Date" htmlFor="due_date">
|
||||
<Input id="due_date" type="date" {...register("due_date")} />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Estimated Cost" htmlFor="estimated_cost">
|
||||
<CurrencyInput
|
||||
value={watch("estimated_cost")}
|
||||
onChange={(v) => setValue("estimated_cost", v)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Contractor" htmlFor="contractor_id">
|
||||
<LookupSelect
|
||||
items={contractorItems}
|
||||
value={watch("contractor_id")}
|
||||
onValueChange={(v) => setValue("contractor_id", v)}
|
||||
placeholder="Select contractor..."
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : isEdit ? "Update Task" : "Create Task"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search } from "lucide-react";
|
||||
import { searchTaskTemplates } from "@/lib/api/lookups";
|
||||
import type { TaskTemplateResponse } from "@/lib/api/lookups";
|
||||
|
||||
interface TemplateSearchProps {
|
||||
onSelect: (template: TaskTemplateResponse) => void;
|
||||
onTitleChange: (title: string) => void;
|
||||
}
|
||||
|
||||
export function TemplateSearch({ onSelect, onTitleChange }: TemplateSearchProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const { data: results = [] } = useQuery({
|
||||
queryKey: ["task-templates", "search", query],
|
||||
queryFn: () => searchTaskTemplates(query),
|
||||
enabled: query.length >= 2,
|
||||
});
|
||||
|
||||
const showDropdown = isFocused && query.length >= 2 && results.length > 0;
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
setQuery(value);
|
||||
onTitleChange(value);
|
||||
},
|
||||
[onTitleChange]
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(template: TaskTemplateResponse) => {
|
||||
setQuery(template.title);
|
||||
onTitleChange(template.title);
|
||||
onSelect(template);
|
||||
setIsFocused(false);
|
||||
},
|
||||
[onSelect, onTitleChange]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (blurTimeoutRef.current) {
|
||||
clearTimeout(blurTimeoutRef.current);
|
||||
}
|
||||
setIsFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
blurTimeoutRef.current = setTimeout(() => {
|
||||
setIsFocused(false);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder="Search templates or type a title..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute z-50 w-full mt-1 rounded-md border bg-popover shadow-lg max-h-60 overflow-y-auto">
|
||||
{results.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleSelect(template)}
|
||||
>
|
||||
<div className="font-medium">{template.title}</div>
|
||||
{template.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{template.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarBadge,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user