diff --git a/scripts/seed.ts b/scripts/seed.ts index 6a684c7..3b438f3 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -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.'); } diff --git a/src/app/admin/flows/[id]/page.tsx b/src/app/admin/flows/[id]/page.tsx new file mode 100644 index 0000000..568fe87 --- /dev/null +++ b/src/app/admin/flows/[id]/page.tsx @@ -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 ( + + {isNew ? 'Create Flow' : 'Save Changes'} + + ); +} + +export default function FlowFormPage({ params }: FlowFormPageProps) { + const [state, formAction] = useFormState(saveFlow, { success: false, message: '', errors: null }); + const [flow, setFlow] = useState(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 ( + + + + + + + + + + + + + + + + + + + + + + + + + ) + } + + return ( + + + + + + Back + + + + {isNew ? 'Create New Flow' : 'Edit Flow'} + + + + + + Flow Details + + {isNew ? 'Fill in the details for your new flow.' : 'Update the details for this flow.'} + + + + + + Flow Name + + {state.errors?.name && {state.errors.name[0]}} + + + Path + + {state.errors?.path && {state.errors.path[0]}} + + + Description + + {state.errors?.description && {state.errors.description[0]}} + + + + + + + ); +} diff --git a/src/app/admin/flows/page.tsx b/src/app/admin/flows/page.tsx new file mode 100644 index 0000000..0be065b --- /dev/null +++ b/src/app/admin/flows/page.tsx @@ -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 ( + + + + Flows + + Manage your interactive application flows. + + + + + + Add New Flow + + + + + + + All Flows + + A list of all flows in your application. + + + + + + + Name + Description + Path + Created + Actions + + + + {flows.length > 0 ? ( + flows.map((flow) => ( + + {flow.name} + {flow.description || 'N/A'} + + {flow.path} + + + {format(new Date(flow.createdAt), 'PPP')} + + + + + + Open menu + + + + + Actions + + Edit + + + View + + + + Delete + + + + + + )) + ) : ( + + + No flows found. + + + )} + + + + + + ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 617b617..9406ddd 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -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({ Leads + + + + + + Flows + + diff --git a/src/lib/actions/flows.ts b/src/lib/actions/flows.ts new file mode 100644 index 0000000..ea27c8c --- /dev/null +++ b/src/lib/actions/flows.ts @@ -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 { + 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 { + 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.' }; + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 0b0c96c..5b94bdb 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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;
{state.errors.name[0]}
{state.errors.path[0]}
{state.errors.description[0]}
+ Manage your interactive application flows. +