Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
4715dcc4f7
commit
f20ca5bd67
@@ -0,0 +1,347 @@
|
||||
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 { Loader2, Upload, Trash2, Building2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/settings/")({
|
||||
component: CompanySettingsPage,
|
||||
});
|
||||
|
||||
const EMPTY = {
|
||||
company_name: "",
|
||||
contact_email: "",
|
||||
contact_phone: "",
|
||||
website: "",
|
||||
address_line1: "",
|
||||
address_line2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
postal_code: "",
|
||||
country: "",
|
||||
invoice_prefix: "",
|
||||
default_tax_rate: "",
|
||||
footer_note: "",
|
||||
logo_storage_path: "" as string | null | "",
|
||||
};
|
||||
|
||||
function CompanySettingsPage() {
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [recordId, setRecordId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({ ...EMPTY });
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const { data } = await supabase
|
||||
.from("firm_settings")
|
||||
.select("*")
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (data) {
|
||||
setRecordId(data.id);
|
||||
setForm({
|
||||
company_name: data.company_name ?? "",
|
||||
contact_email: data.contact_email ?? "",
|
||||
contact_phone: data.contact_phone ?? "",
|
||||
website: data.website ?? "",
|
||||
address_line1: data.address_line1 ?? "",
|
||||
address_line2: data.address_line2 ?? "",
|
||||
city: data.city ?? "",
|
||||
state: data.state ?? "",
|
||||
postal_code: data.postal_code ?? "",
|
||||
country: data.country ?? "",
|
||||
invoice_prefix: data.invoice_prefix ?? "",
|
||||
default_tax_rate:
|
||||
data.default_tax_rate != null ? String(data.default_tax_rate) : "",
|
||||
footer_note: data.footer_note ?? "",
|
||||
logo_storage_path: data.logo_storage_path ?? "",
|
||||
});
|
||||
if (data.logo_storage_path) {
|
||||
const { data: pub } = supabase.storage
|
||||
.from("firm-logos")
|
||||
.getPublicUrl(data.logo_storage_path);
|
||||
setLogoUrl(pub.publicUrl);
|
||||
} else {
|
||||
setLogoUrl(null);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const update = (k: keyof typeof EMPTY, v: string) =>
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const onLogoFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
const ext = file.name.split(".").pop() || "png";
|
||||
const path = `firm-${Date.now()}.${ext}`;
|
||||
const { error } = await supabase.storage
|
||||
.from("firm-logos")
|
||||
.upload(path, file, { upsert: true, contentType: file.type });
|
||||
if (error) {
|
||||
setUploading(false);
|
||||
toast.error("Upload failed", { description: error.message });
|
||||
return;
|
||||
}
|
||||
setForm((f) => ({ ...f, logo_storage_path: path }));
|
||||
const { data: pub } = supabase.storage
|
||||
.from("firm-logos")
|
||||
.getPublicUrl(path);
|
||||
setLogoUrl(pub.publicUrl);
|
||||
setUploading(false);
|
||||
toast.success("Logo uploaded — remember to save");
|
||||
};
|
||||
|
||||
const removeLogo = () => {
|
||||
setForm((f) => ({ ...f, logo_storage_path: "" }));
|
||||
setLogoUrl(null);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
const payload: any = {
|
||||
company_name: form.company_name || null,
|
||||
contact_email: form.contact_email || null,
|
||||
contact_phone: form.contact_phone || null,
|
||||
website: form.website || null,
|
||||
address_line1: form.address_line1 || null,
|
||||
address_line2: form.address_line2 || null,
|
||||
city: form.city || null,
|
||||
state: form.state || null,
|
||||
postal_code: form.postal_code || null,
|
||||
country: form.country || null,
|
||||
invoice_prefix: form.invoice_prefix || null,
|
||||
default_tax_rate: form.default_tax_rate
|
||||
? parseFloat(form.default_tax_rate)
|
||||
: null,
|
||||
footer_note: form.footer_note || null,
|
||||
logo_storage_path: form.logo_storage_path || null,
|
||||
updated_by: user?.id,
|
||||
};
|
||||
const { error } = recordId
|
||||
? await supabase.from("firm_settings").update(payload).eq("id", recordId)
|
||||
: await supabase.from("firm_settings").insert(payload);
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
toast.error("Could not save", { description: error.message });
|
||||
return;
|
||||
}
|
||||
toast.success("Settings 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-4xl">
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-serif text-base flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" /> Company logo
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center gap-6">
|
||||
<div className="h-24 w-24 rounded-md border bg-muted/30 flex items-center justify-center overflow-hidden">
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="Firm logo"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Building2 className="h-8 w-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<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) onLogoFile(f);
|
||||
}}
|
||||
/>
|
||||
<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 logo
|
||||
</Button>
|
||||
{logoUrl && (
|
||||
<Button variant="ghost" size="sm" onClick={removeLogo}>
|
||||
<Trash2 className="h-4 w-4 mr-2" /> Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
PNG, JPG or SVG recommended. Max ~2 MB.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-serif text-base">
|
||||
Company information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Company name">
|
||||
<Input
|
||||
value={form.company_name}
|
||||
onChange={(e) => update("company_name", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Website">
|
||||
<Input
|
||||
value={form.website}
|
||||
onChange={(e) => update("website", e.target.value)}
|
||||
placeholder="https://"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Contact email">
|
||||
<Input
|
||||
type="email"
|
||||
value={form.contact_email}
|
||||
onChange={(e) => update("contact_email", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Contact phone">
|
||||
<Input
|
||||
value={form.contact_phone}
|
||||
onChange={(e) => update("contact_phone", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Address line 1" className="sm:col-span-2">
|
||||
<Input
|
||||
value={form.address_line1}
|
||||
onChange={(e) => update("address_line1", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Address line 2" className="sm:col-span-2">
|
||||
<Input
|
||||
value={form.address_line2}
|
||||
onChange={(e) => update("address_line2", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="City">
|
||||
<Input
|
||||
value={form.city}
|
||||
onChange={(e) => update("city", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="State / Province">
|
||||
<Input
|
||||
value={form.state}
|
||||
onChange={(e) => update("state", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Postal code">
|
||||
<Input
|
||||
value={form.postal_code}
|
||||
onChange={(e) => update("postal_code", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Country">
|
||||
<Input
|
||||
value={form.country}
|
||||
onChange={(e) => update("country", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-serif text-base">
|
||||
Invoice defaults
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Invoice number prefix">
|
||||
<Input
|
||||
value={form.invoice_prefix}
|
||||
onChange={(e) => update("invoice_prefix", e.target.value)}
|
||||
placeholder="e.g. INV-"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Default tax rate (%)">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
value={form.default_tax_rate}
|
||||
onChange={(e) => update("default_tax_rate", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Invoice footer note" className="sm:col-span-2">
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.footer_note}
|
||||
onChange={(e) => update("footer_note", e.target.value)}
|
||||
placeholder="Thank you for your business…"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user