Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 02:58:40 +00:00
co-authored by renee-png
parent 301dcdfe2a
commit d00e394012
3 changed files with 309 additions and 3 deletions
+30
View File
@@ -26,6 +26,7 @@ import { Route as ClientsIndexRouteImport } from './routes/clients.index'
import { Route as CasesIndexRouteImport } from './routes/cases.index'
import { Route as SettingsWorkflowsRouteImport } from './routes/settings.workflows'
import { Route as SettingsWorkflowRouteImport } from './routes/settings.workflow'
import { Route as SettingsProfileRouteImport } from './routes/settings.profile'
import { Route as SettingsFeesRouteImport } from './routes/settings.fees'
import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId'
import { Route as ContactsContactIdRouteImport } from './routes/contacts.$contactId'
@@ -124,6 +125,11 @@ const SettingsWorkflowRoute = SettingsWorkflowRouteImport.update({
path: '/workflow',
getParentRoute: () => SettingsRoute,
} as any)
const SettingsProfileRoute = SettingsProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => SettingsRoute,
} as any)
const SettingsFeesRoute = SettingsFeesRouteImport.update({
id: '/fees',
path: '/fees',
@@ -199,6 +205,7 @@ export interface FileRoutesByFullPath {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/profile': typeof SettingsProfileRoute
'/settings/workflow': typeof SettingsWorkflowRoute
'/settings/workflows': typeof SettingsWorkflowsRoute
'/cases/': typeof CasesIndexRoute
@@ -229,6 +236,7 @@ export interface FileRoutesByTo {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/profile': typeof SettingsProfileRoute
'/settings/workflow': typeof SettingsWorkflowRoute
'/settings/workflows': typeof SettingsWorkflowsRoute
'/cases': typeof CasesIndexRoute
@@ -261,6 +269,7 @@ export interface FileRoutesById {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/profile': typeof SettingsProfileRoute
'/settings/workflow': typeof SettingsWorkflowRoute
'/settings/workflows': typeof SettingsWorkflowsRoute
'/cases/': typeof CasesIndexRoute
@@ -294,6 +303,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/invoices/$invoiceId'
| '/settings/fees'
| '/settings/profile'
| '/settings/workflow'
| '/settings/workflows'
| '/cases/'
@@ -324,6 +334,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/invoices/$invoiceId'
| '/settings/fees'
| '/settings/profile'
| '/settings/workflow'
| '/settings/workflows'
| '/cases'
@@ -355,6 +366,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/invoices/$invoiceId'
| '/settings/fees'
| '/settings/profile'
| '/settings/workflow'
| '/settings/workflows'
| '/cases/'
@@ -523,6 +535,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsWorkflowRouteImport
parentRoute: typeof SettingsRoute
}
'/settings/profile': {
id: '/settings/profile'
path: '/profile'
fullPath: '/settings/profile'
preLoaderRoute: typeof SettingsProfileRouteImport
parentRoute: typeof SettingsRoute
}
'/settings/fees': {
id: '/settings/fees'
path: '/fees'
@@ -612,6 +631,7 @@ declare module '@tanstack/react-router' {
interface SettingsRouteChildren {
SettingsFeesRoute: typeof SettingsFeesRoute
SettingsProfileRoute: typeof SettingsProfileRoute
SettingsWorkflowRoute: typeof SettingsWorkflowRoute
SettingsWorkflowsRoute: typeof SettingsWorkflowsRoute
SettingsIndexRoute: typeof SettingsIndexRoute
@@ -619,6 +639,7 @@ interface SettingsRouteChildren {
const SettingsRouteChildren: SettingsRouteChildren = {
SettingsFeesRoute: SettingsFeesRoute,
SettingsProfileRoute: SettingsProfileRoute,
SettingsWorkflowRoute: SettingsWorkflowRoute,
SettingsWorkflowsRoute: SettingsWorkflowsRoute,
SettingsIndexRoute: SettingsIndexRoute,
@@ -658,3 +679,12 @@ const rootRouteChildren: RootRouteChildren = {
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+271
View File
@@ -0,0 +1,271 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Loader2, Upload, Trash2, User as UserIcon } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/profile")({
component: ProfilePage,
});
const EMPTY = {
full_name: "",
title: "",
phone: "",
bio: "",
timezone: "",
avatar_url: "" as string | null | "",
};
function initials(name: string, email: string) {
const src = name?.trim() || email || "";
return src.split(/\s+/).map((p) => p[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
}
function ProfilePage() {
const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [form, setForm] = useState({ ...EMPTY });
const [email, setEmail] = useState("");
const fileInput = useRef<HTMLInputElement>(null);
const load = async () => {
if (!user) return;
setLoading(true);
const { data } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.maybeSingle();
if (data) {
setEmail(data.email || user.email || "");
setForm({
full_name: data.full_name ?? "",
title: (data as any).title ?? "",
phone: (data as any).phone ?? "",
bio: (data as any).bio ?? "",
timezone: (data as any).timezone ?? "",
avatar_url: (data as any).avatar_url ?? "",
});
}
setLoading(false);
};
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id]);
const update = (k: keyof typeof EMPTY, v: string) =>
setForm((f) => ({ ...f, [k]: v }));
const onAvatarFile = async (file: File) => {
if (!user) return;
if (file.size > 5 * 1024 * 1024) {
toast.error("Image must be under 5 MB");
return;
}
setUploading(true);
const ext = (file.name.split(".").pop() || "png").toLowerCase();
// Path must start with the user's id for the storage policy to allow it.
const path = `${user.id}/avatar-${Date.now()}.${ext}`;
const { error } = await supabase.storage
.from("avatars")
.upload(path, file, { upsert: true, contentType: file.type, cacheControl: "3600" });
if (error) {
setUploading(false);
toast.error("Upload failed", { description: error.message });
return;
}
const { data: pub } = supabase.storage.from("avatars").getPublicUrl(path);
const url = pub.publicUrl;
// Persist immediately so it appears in the sidebar without a save click.
const { error: upErr } = await supabase
.from("profiles")
.update({ avatar_url: url })
.eq("id", user.id);
setUploading(false);
if (upErr) {
toast.error("Could not save avatar", { description: upErr.message });
return;
}
setForm((f) => ({ ...f, avatar_url: url }));
toast.success("Profile photo updated");
};
const removeAvatar = async () => {
if (!user) return;
const { error } = await supabase
.from("profiles")
.update({ avatar_url: null })
.eq("id", user.id);
if (error) {
toast.error("Could not remove", { description: error.message });
return;
}
setForm((f) => ({ ...f, avatar_url: "" }));
toast.success("Photo removed");
};
const save = async () => {
if (!user) return;
setSaving(true);
const { error } = await supabase
.from("profiles")
.update({
full_name: form.full_name || "",
title: form.title || null,
phone: form.phone || null,
bio: form.bio || null,
timezone: form.timezone || null,
})
.eq("id", user.id);
setSaving(false);
if (error) {
toast.error("Could not save", { description: error.message });
return;
}
toast.success("Profile saved");
load();
};
if (loading) {
return (
<div className="flex justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6 max-w-3xl">
<Card className="border-border/60">
<CardHeader>
<CardTitle className="font-serif text-base flex items-center gap-2">
<UserIcon className="h-4 w-4 text-muted-foreground" /> Profile photo
</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-6">
<Avatar className="h-24 w-24">
{form.avatar_url ? <AvatarImage src={form.avatar_url} alt="Avatar" /> : null}
<AvatarFallback className="text-lg">
{initials(form.full_name, email)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<input
ref={fileInput}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onAvatarFile(f);
e.target.value = "";
}}
/>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => fileInput.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
Upload photo
</Button>
{form.avatar_url && (
<Button variant="ghost" size="sm" onClick={removeAvatar}>
<Trash2 className="h-4 w-4 mr-2" /> Remove
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">PNG or JPG, up to 5 MB.</p>
</div>
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader>
<CardTitle className="font-serif text-base">Personal information</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Field label="Full name">
<Input
value={form.full_name}
onChange={(e) => update("full_name", e.target.value)}
/>
</Field>
<Field label="Email">
<Input value={email} disabled />
</Field>
<Field label="Job title">
<Input
value={form.title}
onChange={(e) => update("title", e.target.value)}
placeholder="Attorney, Paralegal, etc."
/>
</Field>
<Field label="Phone">
<Input
value={form.phone}
onChange={(e) => update("phone", e.target.value)}
/>
</Field>
<Field label="Timezone">
<Input
value={form.timezone}
onChange={(e) => update("timezone", e.target.value)}
placeholder="America/New_York"
/>
</Field>
<Field label="Bio" className="sm:col-span-2">
<Textarea
rows={4}
value={form.bio}
onChange={(e) => update("bio", e.target.value)}
placeholder="A short introduction shown on your profile."
/>
</Field>
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={save} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save changes
</Button>
</div>
</div>
);
}
function Field({
label,
className,
children,
}: {
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={`space-y-1.5 ${className ?? ""}`}>
<Label className="text-xs">{label}</Label>
{children}
</div>
);
}
+8 -3
View File
@@ -2,6 +2,7 @@ import { createFileRoute, Link, useLocation } from "@tanstack/react-router";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
import { Outlet } from "@tanstack/react-router";
import { useAuth } from "@/lib/auth";
import { cn } from "@/lib/utils";
export const Route = createFileRoute("/settings")({
@@ -9,22 +10,26 @@ export const Route = createFileRoute("/settings")({
});
const TABS = [
{ to: "/settings/profile", label: "My profile", anyone: true },
{ to: "/settings", label: "Company", exact: true },
{ to: "/settings/fees", label: "Fee schedule" },
{ to: "/settings/workflow", label: "Collections workflow" },
{ to: "/settings/workflows", label: "Task workflows" },
];
function SettingsLayout() {
const location = useLocation();
const { isAdmin } = useAuth();
const visibleTabs = TABS.filter((t) => t.anyone || isAdmin);
return (
<ProtectedLayout adminOnly>
<ProtectedLayout>
<PageContainer>
<PageHeader
title="Settings"
description="Configure firm-wide information and billing defaults."
/>
<div className="flex gap-1 border-b mb-6">
{TABS.map((t) => {
<div className="flex gap-1 border-b mb-6 overflow-x-auto">
{visibleTabs.map((t) => {
const active = t.exact
? location.pathname === t.to
: location.pathname.startsWith(t.to);