261 lines
9.1 KiB
TypeScript
261 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Plus, Trash2 } from "lucide-react";
|
|
import { addUser, deleteUser, sendTestEmail, type ActionResult } from "@/actions/settings";
|
|
import { updateDefaultPrice } from "@/actions/pricing";
|
|
import { toast } from "sonner";
|
|
import type { AppUser, FuelSettings } from "@/lib/types";
|
|
|
|
const initial: ActionResult = { ok: false, error: "" };
|
|
|
|
interface Props {
|
|
currentUserId: string;
|
|
settings: FuelSettings | null;
|
|
users: AppUser[];
|
|
}
|
|
|
|
export function SettingsForms({ currentUserId, settings, users }: Props) {
|
|
const router = useRouter();
|
|
const [defaultPrice, setDefaultPrice] = useState(settings?.default_price_per_litre || 0);
|
|
|
|
function onSaveDefault() {
|
|
startTransition(async () => {
|
|
const res = await updateDefaultPrice(defaultPrice);
|
|
if (res.ok) {
|
|
toast.success("Default price saved");
|
|
router.refresh();
|
|
} else {
|
|
toast.error(res.error);
|
|
}
|
|
});
|
|
}
|
|
|
|
const [, startTransition] = useTransition();
|
|
const [testEmail, setTestEmail] = useState("");
|
|
|
|
function onSendTest() {
|
|
if (!testEmail) {
|
|
toast.error("Enter a recipient email");
|
|
return;
|
|
}
|
|
startTransition(async () => {
|
|
const res = await sendTestEmail(testEmail);
|
|
if (res.ok) toast.success("Test email sent");
|
|
else toast.error(res.error);
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
|
|
<p className="text-sm text-muted-foreground">Fuel price, email, users, and price increase</p>
|
|
</div>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Default fuel price</CardTitle>
|
|
<CardDescription>Used when no per-customer rule applies</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-end gap-2">
|
|
<div className="space-y-2 flex-1">
|
|
<Label htmlFor="dp">Default price (R / L)</Label>
|
|
<Input
|
|
id="dp"
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={defaultPrice}
|
|
onChange={(e) => setDefaultPrice(Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
<Button onClick={onSaveDefault}>Save</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Fuel price increase</CardTitle>
|
|
<CardDescription>Quickly apply a cents increase to all customers (use Pricing page for details)</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Button variant="outline" asChild>
|
|
<a href="/pricing">Go to Pricing →</a>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Email delivery</CardTitle>
|
|
<CardDescription>Customer statements are sent through Gmail SMTP.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="rounded-md border bg-muted/30 p-3 text-sm">
|
|
<p className="font-medium">Configured sender</p>
|
|
<p className="text-muted-foreground">rjbulk01@gmail.com</p>
|
|
<p className="mt-2 text-muted-foreground">
|
|
Gmail credentials are managed by the deployment environment and are not stored in PocketBase.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-end gap-2 pt-2 border-t">
|
|
<div className="space-y-2 flex-1">
|
|
<Label htmlFor="test">Send test email to:</Label>
|
|
<Input id="test" type="email" value={testEmail} onChange={(e) => setTestEmail(e.target.value)} />
|
|
</div>
|
|
<Button type="button" variant="outline" onClick={onSendTest}>
|
|
Send test
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<UsersSection currentUserId={currentUserId} users={users} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UsersSection({ currentUserId, users }: { currentUserId: string; users: AppUser[] }) {
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [, startTransition] = useTransition();
|
|
|
|
function onDelete(id: string) {
|
|
startTransition(async () => {
|
|
const res = await deleteUser(id);
|
|
if (res.ok) {
|
|
toast.success("User deleted");
|
|
setDeletingId(null);
|
|
router.refresh();
|
|
} else {
|
|
toast.error(res.error);
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<div>
|
|
<CardTitle>Users</CardTitle>
|
|
<CardDescription>{users.length} user{users.length !== 1 ? "s" : ""}</CardDescription>
|
|
</div>
|
|
<Button onClick={() => setOpen(true)} size="sm">
|
|
<Plus className="h-4 w-4" /> Add user
|
|
</Button>
|
|
</CardHeader>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Verified</TableHead>
|
|
<TableHead className="w-12"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{users.map((u) => (
|
|
<TableRow key={u.id}>
|
|
<TableCell className="font-medium">{u.name || "—"}</TableCell>
|
|
<TableCell className="text-muted-foreground">{u.email}</TableCell>
|
|
<TableCell>{u.verified ? "Yes" : "No"}</TableCell>
|
|
<TableCell>
|
|
{u.id !== currentUserId && (
|
|
<Button variant="ghost" size="icon" onClick={() => setDeletingId(u.id)}>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
<AddUserDialog open={open} onOpenChange={setOpen} />
|
|
<Dialog open={!!deletingId} onOpenChange={(v) => !v && setDeletingId(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete this user?</DialogTitle>
|
|
<DialogDescription>They will lose access immediately. This cannot be undone.</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => setDeletingId(null)}>Cancel</Button>
|
|
<Button variant="destructive" onClick={() => deletingId && onDelete(deletingId)}>Delete</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function AddUserDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
|
const router = useRouter();
|
|
const ref = useRef<HTMLFormElement>(null);
|
|
const [pending, startTransition] = useTransition();
|
|
const [state, action] = useActionState(addUser, initial);
|
|
|
|
useEffect(() => {
|
|
if (state.ok) {
|
|
toast.success("User created");
|
|
onOpenChange(false);
|
|
ref.current?.reset();
|
|
router.refresh();
|
|
} else if (state.error) {
|
|
toast.error(state.error);
|
|
}
|
|
}, [state, router, onOpenChange]);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Add user</DialogTitle>
|
|
<DialogDescription>Create a new login for someone on your team.</DialogDescription>
|
|
</DialogHeader>
|
|
<form ref={ref} action={action} className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="u-name">Name</Label>
|
|
<Input id="u-name" name="name" required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="u-username">Username</Label>
|
|
<Input id="u-username" name="username" required minLength={3} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="u-email">Email</Label>
|
|
<Input id="u-email" name="email" type="email" required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="u-pwd">Password</Label>
|
|
<Input id="u-pwd" name="password" type="password" required minLength={8} />
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
|
|
<Button type="submit" disabled={pending}>
|
|
{pending ? "Creating…" : "Create user"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|