on the admin dashboard create a tab called "Flows" and add the estimate

This commit is contained in:
Leon Serfaty G
2025-07-18 04:51:03 +00:00
parent bf1583ba37
commit 6954cf4364
6 changed files with 388 additions and 1 deletions
+134
View File
@@ -0,0 +1,134 @@
'use client';
import { useEffect, useState } from 'react';
import { useFormState } from 'react-dom';
import { getFlow, saveFlow, type Flow } from '@/lib/actions/flows';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { useToast } from '@/hooks/use-toast';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
interface FlowFormPageProps {
params: { id: string };
}
function SubmitButton({ isNew }: { isNew: boolean }) {
return (
<Button type="submit">
{isNew ? 'Create Flow' : 'Save Changes'}
</Button>
);
}
export default function FlowFormPage({ params }: FlowFormPageProps) {
const [state, formAction] = useFormState(saveFlow, { success: false, message: '', errors: null });
const [flow, setFlow] = useState<Flow | null>(null);
const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast();
const isNew = params.id === 'new';
useEffect(() => {
if (!isNew) {
const fetchFlow = async () => {
setIsLoading(true);
const existingFlow = await getFlow(Number(params.id));
setFlow(existingFlow);
setIsLoading(false);
};
fetchFlow();
} else {
setIsLoading(false);
}
}, [params.id, isNew]);
useEffect(() => {
if (state.message) {
toast({
title: state.success ? 'Success!' : 'Error',
description: state.message,
variant: state.success ? 'default' : 'destructive',
});
}
}, [state, toast]);
if (isLoading) {
return (
<div>
<Skeleton className="h-8 w-48 mb-8" />
<Card>
<CardHeader>
<Skeleton className="h-7 w-1/3" />
<Skeleton className="h-5 w-2/3" />
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-20 w-full" />
</div>
</CardContent>
</Card>
<Skeleton className="h-10 w-32 mt-6" />
</div>
)
}
return (
<form action={formAction} className="space-y-8">
<div className="flex items-center gap-4">
<Button variant="outline" size="icon" asChild>
<Link href="/admin/flows">
<ChevronLeft className="h-4 w-4" />
<span className="sr-only">Back</span>
</Link>
</Button>
<h1 className="text-3xl font-bold tracking-tight">
{isNew ? 'Create New Flow' : 'Edit Flow'}
</h1>
</div>
<Card>
<CardHeader>
<CardTitle>Flow Details</CardTitle>
<CardDescription>
{isNew ? 'Fill in the details for your new flow.' : 'Update the details for this flow.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<input type="hidden" name="id" value={flow?.id} />
<div className="space-y-2">
<Label htmlFor="name">Flow Name</Label>
<Input id="name" name="name" defaultValue={flow?.name} required />
{state.errors?.name && <p className="text-sm text-destructive">{state.errors.name[0]}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="path">Path</Label>
<Input id="path" name="path" defaultValue={flow?.path} placeholder="/my-cool-flow" required />
{state.errors?.path && <p className="text-sm text-destructive">{state.errors.path[0]}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea id="description" name="description" defaultValue={flow?.description || ''} placeholder="A brief description of what this flow does." />
{state.errors?.description && <p className="text-sm text-destructive">{state.errors.description[0]}</p>}
</div>
</CardContent>
</Card>
<SubmitButton isNew={isNew} />
</form>
);
}
+107
View File
@@ -0,0 +1,107 @@
import Link from 'next/link';
import { getFlows, type Flow } from '@/lib/actions/flows';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { MoreHorizontal, PlusCircle } from 'lucide-react';
import { format } from 'date-fns';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from '@/components/ui/badge';
export default async function FlowsPage() {
const flows = await getFlows();
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Flows</h1>
<p className="mt-2 text-muted-foreground">
Manage your interactive application flows.
</p>
</div>
<Link href="/admin/flows/new">
<Button>
<PlusCircle className="mr-2 h-4 w-4" />
Add New Flow
</Button>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle>All Flows</CardTitle>
<CardDescription>
A list of all flows in your application.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Path</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{flows.length > 0 ? (
flows.map((flow) => (
<TableRow key={flow.id}>
<TableCell className="font-medium">{flow.name}</TableCell>
<TableCell>{flow.description || 'N/A'}</TableCell>
<TableCell>
<Badge variant="outline">{flow.path}</Badge>
</TableCell>
<TableCell>
{format(new Date(flow.createdAt), 'PPP')}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<Link href={`/admin/flows/${flow.id}`}>
<DropdownMenuItem>Edit</DropdownMenuItem>
</Link>
<Link href={`${flow.path}`} target="_blank">
<DropdownMenuItem>View</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:bg-destructive/10 focus:text-destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="text-center h-24">
No flows found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
+10 -1
View File
@@ -25,7 +25,8 @@ import {
Mails,
Send,
ClipboardList,
ExternalLink
ExternalLink,
Workflow
} from "lucide-react"
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -61,6 +62,14 @@ function AdminLayout({
<span>Leads</span>
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
<SidebarMenuItem>
<Link href="/admin/flows">
<SidebarMenuButton tooltip="Flows">
<Workflow />
<span>Flows</span>
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
<SidebarMenuItem>
<Link href="/admin/embed">
+99
View File
@@ -0,0 +1,99 @@
'use server';
import db from '@/lib/db';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export type Flow = {
id: number;
name: string;
description: string | null;
path: string;
createdAt: string;
updatedAt: string;
};
const flowSchema = z.object({
id: z.coerce.number().optional(),
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
path: z.string().min(1, 'Path is required').startsWith('/', { message: 'Path must start with /' }),
});
export async function getFlows(): Promise<Flow[]> {
try {
const stmt = db.prepare(
'SELECT id, name, description, path, createdAt, updatedAt FROM flows ORDER BY createdAt DESC'
);
const flows = stmt.all() as Flow[];
return flows;
} catch (error) {
console.error('Failed to fetch flows:', error);
return [];
}
}
export async function getFlow(id: number): Promise<Flow | null> {
try {
const stmt = db.prepare('SELECT * FROM flows WHERE id = ?');
const flow = stmt.get(id) as Flow | undefined;
return flow || null;
} catch (error) {
console.error(`Failed to fetch flow with id ${id}:`, error);
return null;
}
}
export async function saveFlow(formData: FormData) {
const validatedFields = flowSchema.safeParse(Object.fromEntries(formData.entries()));
if (!validatedFields.success) {
return {
success: false,
message: 'Invalid fields.',
errors: validatedFields.error.flatten().fieldErrors,
};
}
const { id, name, description, path } = validatedFields.data;
try {
if (id) {
// Update existing flow
const stmt = db.prepare(
'UPDATE flows SET name = ?, description = ?, path = ?, updatedAt = CURRENT_TIMESTAMP WHERE id = ?'
);
stmt.run(name, description || null, path, id);
} else {
// Create new flow
const stmt = db.prepare(
'INSERT INTO flows (name, description, path) VALUES (?, ?, ?)'
);
stmt.run(name, description || null, path);
}
} catch (error: any) {
console.error('Failed to save flow:', error);
return {
success: false,
message: error.code === 'SQLITE_CONSTRAINT_UNIQUE' ? 'A flow with this path already exists.' : 'An internal error occurred.',
errors: null,
};
}
revalidatePath('/admin/flows');
redirect('/admin/flows');
}
export async function deleteFlow(id: number): Promise<{ success: boolean, message: string }> {
try {
const stmt = db.prepare('DELETE FROM flows WHERE id = ?');
stmt.run(id);
revalidatePath('/admin/flows');
return { success: true, message: 'Flow deleted successfully.' };
} catch (error) {
console.error('Failed to delete flow:', error);
return { success: false, message: 'An internal error occurred.' };
}
}
+11
View File
@@ -39,5 +39,16 @@ db.exec(`
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS flows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
path TEXT NOT NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
export default db;