From bbf3999c7949ec6af6bf16f35da9786294cd5630 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 5 Dec 2025 09:07:53 -0600 Subject: [PATCH] Add task templates API and admin management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TaskTemplate model with category and frequency support - Add task template repository with CRUD and search operations - Add task template service layer - Add public API endpoints for templates (no auth required): - GET /api/tasks/templates/ - list all templates - GET /api/tasks/templates/grouped/ - templates grouped by category - GET /api/tasks/templates/search/?q= - search templates - GET /api/tasks/templates/by-category/:id/ - templates by category - GET /api/tasks/templates/:id/ - single template - Add admin panel for task template management (CRUD) - Add admin API endpoints for templates - Add seed file with predefined task templates - Add i18n translations for template errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- admin/src/app/(dashboard)/settings/page.tsx | 3 +- .../app/(dashboard)/task-templates/page.tsx | 445 ++++++++++++++++++ admin/src/components/app-sidebar.tsx | 2 + admin/src/lib/api.ts | 101 ++++ docs/Dokku_notes | 151 ++++++ internal/admin/handlers/settings_handler.go | 20 +- .../admin/handlers/task_template_handler.go | 321 +++++++++++++ internal/admin/routes.go | 14 + internal/database/database.go | 1 + internal/dto/responses/task_template.go | 134 ++++++ internal/handlers/task_template_handler.go | 106 +++++ internal/i18n/translations/en.json | 3 + internal/models/task_template.go | 22 + internal/repositories/task_template_repo.go | 123 +++++ internal/router/router.go | 17 +- internal/services/task_template_service.go | 69 +++ seeds/003_task_templates.sql | 106 +++++ 17 files changed, 1634 insertions(+), 4 deletions(-) create mode 100644 admin/src/app/(dashboard)/task-templates/page.tsx create mode 100644 docs/Dokku_notes create mode 100644 internal/admin/handlers/task_template_handler.go create mode 100644 internal/dto/responses/task_template.go create mode 100644 internal/handlers/task_template_handler.go create mode 100644 internal/models/task_template.go create mode 100644 internal/repositories/task_template_repo.go create mode 100644 internal/services/task_template_service.go create mode 100644 seeds/003_task_templates.sql diff --git a/admin/src/app/(dashboard)/settings/page.tsx b/admin/src/app/(dashboard)/settings/page.tsx index f643fed..4409c3c 100644 --- a/admin/src/app/(dashboard)/settings/page.tsx +++ b/admin/src/app/(dashboard)/settings/page.tsx @@ -74,7 +74,7 @@ export default function SettingsPage() { Seed Lookup Data - Populate or refresh static lookup tables (categories, priorities, statuses, etc.) + Populate or refresh static lookup tables and task templates @@ -97,6 +97,7 @@ export default function SettingsPage() {
  • Task categories, priorities, statuses, frequencies
  • Contractor specialties
  • Subscription tiers and feature benefits
  • +
  • Task templates (60+ predefined tasks)
  • Existing data will be preserved or updated. diff --git a/admin/src/app/(dashboard)/task-templates/page.tsx b/admin/src/app/(dashboard)/task-templates/page.tsx new file mode 100644 index 0000000..c56cdd6 --- /dev/null +++ b/admin/src/app/(dashboard)/task-templates/page.tsx @@ -0,0 +1,445 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { LayoutTemplate, Trash2, Search, Plus, Edit, ChevronLeft, ChevronRight, Power, X } from 'lucide-react'; +import { toast } from 'sonner'; + +import { taskTemplatesApi, lookupsApi, type TaskTemplate } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; + +export default function TaskTemplatesPage() { + const queryClient = useQueryClient(); + const [params, setParams] = useState<{ + page: number; + per_page: number; + search: string; + category_id?: number; + frequency_id?: number; + is_active?: boolean; + }>({ page: 1, per_page: 20, search: '' }); + const [search, setSearch] = useState(''); + const [filterCategory, setFilterCategory] = useState(''); + const [filterFrequency, setFilterFrequency] = useState(''); + const [filterActive, setFilterActive] = useState(''); + const [editingTemplate, setEditingTemplate] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [formData, setFormData] = useState({ + title: '', + description: '', + category_id: undefined as number | undefined, + frequency_id: undefined as number | undefined, + icon_ios: '', + icon_android: '', + tags: '', + display_order: 0, + is_active: true, + }); + + const { data, isLoading } = useQuery({ + queryKey: ['task-templates', params], + queryFn: () => taskTemplatesApi.list(params), + }); + + // Fetch categories and frequencies for dropdowns + const { data: categories } = useQuery({ + queryKey: ['lookup-categories'], + queryFn: () => lookupsApi.categories.list(), + }); + + const { data: frequencies } = useQuery({ + queryKey: ['lookup-frequencies'], + queryFn: () => lookupsApi.frequencies.list(), + }); + + const createMutation = useMutation({ + mutationFn: taskTemplatesApi.create, + onSuccess: () => { + toast.success('Task template created'); + queryClient.invalidateQueries({ queryKey: ['task-templates'] }); + setIsCreating(false); + resetForm(); + }, + onError: () => toast.error('Failed to create task template'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: any }) => taskTemplatesApi.update(id, data), + onSuccess: () => { + toast.success('Task template updated'); + queryClient.invalidateQueries({ queryKey: ['task-templates'] }); + setEditingTemplate(null); + resetForm(); + }, + onError: () => toast.error('Failed to update task template'), + }); + + const deleteMutation = useMutation({ + mutationFn: taskTemplatesApi.delete, + onSuccess: () => { + toast.success('Task template deleted'); + queryClient.invalidateQueries({ queryKey: ['task-templates'] }); + }, + onError: () => toast.error('Failed to delete task template'), + }); + + const toggleActiveMutation = useMutation({ + mutationFn: taskTemplatesApi.toggleActive, + onSuccess: () => { + toast.success('Template status updated'); + queryClient.invalidateQueries({ queryKey: ['task-templates'] }); + }, + onError: () => toast.error('Failed to update template status'), + }); + + const resetForm = () => { + setFormData({ + title: '', + description: '', + category_id: undefined, + frequency_id: undefined, + icon_ios: '', + icon_android: '', + tags: '', + display_order: 0, + is_active: true, + }); + }; + + const handleEdit = (template: TaskTemplate) => { + setEditingTemplate(template); + setFormData({ + title: template.title, + description: template.description, + category_id: template.category_id ?? undefined, + frequency_id: template.frequency_id ?? undefined, + icon_ios: template.icon_ios, + icon_android: template.icon_android, + tags: template.tags, + display_order: template.display_order, + is_active: template.is_active, + }); + }; + + const handleSubmit = () => { + if (editingTemplate) { + updateMutation.mutate({ id: editingTemplate.id, data: formData }); + } else { + createMutation.mutate(formData); + } + }; + + const totalPages = data ? Math.ceil(data.total / params.per_page) : 0; + + return ( +
    +
    +
    +

    Task Templates

    +

    Manage predefined task templates for autocomplete

    +
    + +
    + + {/* Search and Filters */} +
    +
    { + e.preventDefault(); + setParams({ + ...params, + search, + page: 1, + category_id: filterCategory && filterCategory !== 'all' ? parseInt(filterCategory) : undefined, + frequency_id: filterFrequency && filterFrequency !== 'all' ? parseInt(filterFrequency) : undefined, + is_active: filterActive === '' || filterActive === 'all' ? undefined : filterActive === 'true', + }); + }} className="flex flex-wrap gap-2"> +
    + + setSearch(e.target.value)} className="pl-9" /> +
    + + + + + {(search || (filterCategory && filterCategory !== 'all') || (filterFrequency && filterFrequency !== 'all') || (filterActive && filterActive !== 'all')) && ( + + )} +
    +
    + +
    + + + + Order + Title + Category + Frequency + iOS Icon + Active + Actions + + + + {isLoading ? ( + Loading... + ) : data?.data?.length === 0 ? ( + No task templates found. Click "Seed Lookup Data" in Settings to add templates. + ) : ( + data?.data?.map((template) => ( + + {template.display_order} + +
    {template.title}
    + {template.description && ( +
    {template.description}
    + )} +
    + + {template.category?.name || 'Uncategorized'} + + {template.frequency?.name || '-'} + + {template.icon_ios || '-'} + + + + {template.is_active ? 'Active' : 'Inactive'} + + + +
    + + + + + + + + + Delete Template? + This will permanently delete "{template.title}". + + + Cancel + deleteMutation.mutate(template.id)} className="bg-destructive">Delete + + + +
    +
    +
    + )) + )} +
    +
    +
    + + {data && totalPages > 1 && ( +
    +
    + Showing {((params.page - 1) * params.per_page) + 1} - {Math.min(params.page * params.per_page, data.total)} of {data.total} +
    +
    + + +
    +
    + )} + + {/* Create/Edit Dialog */} + { if (!open) { setIsCreating(false); setEditingTemplate(null); } }}> + + + {editingTemplate ? 'Edit Task Template' : 'Create Task Template'} + +
    +
    + + setFormData({ ...formData, title: e.target.value })} + placeholder="e.g., Clean HVAC Filter" + /> +
    +
    + +