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
|
// Check if the hourly_rate setting already exists
|
||||||
const settingStmt = db.prepare('SELECT * FROM settings WHERE key = ?');
|
const settingStmt = db.prepare('SELECT * FROM settings WHERE key = ?');
|
||||||
const hourlyRateSetting = settingStmt.get('hourly_rate');
|
const hourlyRateSetting = settingStmt.get('hourly_rate');
|
||||||
@@ -98,6 +111,20 @@ function seed() {
|
|||||||
console.log('Default email template updated.');
|
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.');
|
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,
|
Mails,
|
||||||
Send,
|
Send,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
ExternalLink
|
ExternalLink,
|
||||||
|
Workflow
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
@@ -61,6 +62,14 @@ function AdminLayout({
|
|||||||
<span>Leads</span>
|
<span>Leads</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</Link>
|
</Link>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<Link href="/admin/flows">
|
||||||
|
<SidebarMenuButton tooltip="Flows">
|
||||||
|
<Workflow />
|
||||||
|
<span>Flows</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</Link>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<Link href="/admin/embed">
|
<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;
|
export default db;
|
||||||
|
|||||||
Reference in New Issue
Block a user