on the admin dashboard create a tab called "Flows" and add the estimate
This commit is contained in:
@@ -23,6 +23,19 @@ function seed() {
|
||||
)
|
||||
`);
|
||||
|
||||
// Create flows table
|
||||
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
|
||||
)
|
||||
`);
|
||||
|
||||
|
||||
// Check if the hourly_rate setting already exists
|
||||
const settingStmt = db.prepare('SELECT * FROM settings WHERE key = ?');
|
||||
const hourlyRateSetting = settingStmt.get('hourly_rate');
|
||||
@@ -98,6 +111,20 @@ function seed() {
|
||||
console.log('Default email template updated.');
|
||||
}
|
||||
|
||||
// Seed default flow
|
||||
const flowStmt = db.prepare('SELECT * FROM flows WHERE id = ?');
|
||||
const defaultFlow = flowStmt.get(1);
|
||||
|
||||
if (!defaultFlow) {
|
||||
const insertFlow = db.prepare(
|
||||
"INSERT INTO flows (id, name, description, path) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
insertFlow.run(1, 'Cost Estimator', 'The main cost estimation tool for clients.', '/');
|
||||
console.log('Default flow created.');
|
||||
} else {
|
||||
console.log('Default flow already exists.');
|
||||
}
|
||||
|
||||
|
||||
console.log('Seeding complete.');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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.' };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user