Add task templates API and admin management

- 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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-05 09:07:53 -06:00
parent e824c90877
commit bbf3999c79
17 changed files with 1634 additions and 4 deletions
+2 -1
View File
@@ -74,7 +74,7 @@ export default function SettingsPage() {
Seed Lookup Data
</CardTitle>
<CardDescription>
Populate or refresh static lookup tables (categories, priorities, statuses, etc.)
Populate or refresh static lookup tables and task templates
</CardDescription>
</CardHeader>
<CardContent>
@@ -97,6 +97,7 @@ export default function SettingsPage() {
<li>Task categories, priorities, statuses, frequencies</li>
<li>Contractor specialties</li>
<li>Subscription tiers and feature benefits</li>
<li><strong>Task templates (60+ predefined tasks)</strong></li>
</ul>
Existing data will be preserved or updated.
</AlertDialogDescription>
@@ -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<string>('');
const [filterFrequency, setFilterFrequency] = useState<string>('');
const [filterActive, setFilterActive] = useState<string>('');
const [editingTemplate, setEditingTemplate] = useState<TaskTemplate | null>(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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Task Templates</h1>
<p className="text-muted-foreground">Manage predefined task templates for autocomplete</p>
</div>
<Button onClick={() => { setIsCreating(true); resetForm(); }}>
<Plus className="mr-2 h-4 w-4" />
Add Template
</Button>
</div>
{/* Search and Filters */}
<div className="space-y-4">
<form onSubmit={(e) => {
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">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="Search by title or tags..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9" />
</div>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All Categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories?.map((cat) => (
<SelectItem key={cat.id} value={cat.id.toString()}>{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterFrequency} onValueChange={setFilterFrequency}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All Frequencies" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Frequencies</SelectItem>
{frequencies?.map((freq) => (
<SelectItem key={freq.id} value={freq.id.toString()}>{freq.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterActive} onValueChange={setFilterActive}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="true">Active</SelectItem>
<SelectItem value="false">Inactive</SelectItem>
</SelectContent>
</Select>
<Button type="submit" variant="secondary">Apply</Button>
{(search || (filterCategory && filterCategory !== 'all') || (filterFrequency && filterFrequency !== 'all') || (filterActive && filterActive !== 'all')) && (
<Button
type="button"
variant="ghost"
onClick={() => {
setSearch('');
setFilterCategory('');
setFilterFrequency('');
setFilterActive('');
setParams({ page: 1, per_page: 20, search: '' });
}}
>
<X className="h-4 w-4 mr-1" />
Clear
</Button>
)}
</form>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Order</TableHead>
<TableHead>Title</TableHead>
<TableHead>Category</TableHead>
<TableHead>Frequency</TableHead>
<TableHead>iOS Icon</TableHead>
<TableHead>Active</TableHead>
<TableHead className="w-32">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow><TableCell colSpan={7} className="text-center py-8">Loading...</TableCell></TableRow>
) : data?.data?.length === 0 ? (
<TableRow><TableCell colSpan={7} className="text-center py-8">No task templates found. Click &quot;Seed Lookup Data&quot; in Settings to add templates.</TableCell></TableRow>
) : (
data?.data?.map((template) => (
<TableRow key={template.id}>
<TableCell className="text-muted-foreground">{template.display_order}</TableCell>
<TableCell className="font-medium max-w-xs">
<div className="truncate">{template.title}</div>
{template.description && (
<div className="text-xs text-muted-foreground truncate">{template.description}</div>
)}
</TableCell>
<TableCell>
<Badge variant="outline">{template.category?.name || 'Uncategorized'}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{template.frequency?.name || '-'}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{template.icon_ios || '-'}
</TableCell>
<TableCell>
<Badge variant={template.is_active ? 'default' : 'secondary'}>
{template.is_active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => toggleActiveMutation.mutate(template.id)}
title={template.is_active ? 'Deactivate' : 'Activate'}
>
<Power className={`h-4 w-4 ${template.is_active ? 'text-green-600' : 'text-muted-foreground'}`} />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleEdit(template)}>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-red-600 hover:text-red-700">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Template?</AlertDialogTitle>
<AlertDialogDescription>This will permanently delete &quot;{template.title}&quot;.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteMutation.mutate(template.id)} className="bg-destructive">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{data && totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {((params.page - 1) * params.per_page) + 1} - {Math.min(params.page * params.per_page, data.total)} of {data.total}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={params.page <= 1} onClick={() => setParams({ ...params, page: params.page - 1 })}>
<ChevronLeft className="h-4 w-4" /> Previous
</Button>
<Button variant="outline" size="sm" disabled={params.page >= totalPages} onClick={() => setParams({ ...params, page: params.page + 1 })}>
Next <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Create/Edit Dialog */}
<Dialog open={isCreating || !!editingTemplate} onOpenChange={(open) => { if (!open) { setIsCreating(false); setEditingTemplate(null); } }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingTemplate ? 'Edit Task Template' : 'Create Task Template'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto">
<div className="space-y-2">
<Label>Title *</Label>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="e.g., Clean HVAC Filter"
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Detailed instructions for this task..."
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Category</Label>
<Select
value={formData.category_id?.toString() ?? ''}
onValueChange={(value) => setFormData({ ...formData, category_id: value ? parseInt(value) : undefined })}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories?.map((cat) => (
<SelectItem key={cat.id} value={cat.id.toString()}>{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Frequency</Label>
<Select
value={formData.frequency_id?.toString() ?? ''}
onValueChange={(value) => setFormData({ ...formData, frequency_id: value ? parseInt(value) : undefined })}
>
<SelectTrigger>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent>
{frequencies?.map((freq) => (
<SelectItem key={freq.id} value={freq.id.toString()}>{freq.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>iOS Icon (SF Symbol)</Label>
<Input
value={formData.icon_ios}
onChange={(e) => setFormData({ ...formData, icon_ios: e.target.value })}
placeholder="e.g., wrench.fill"
/>
</div>
<div className="space-y-2">
<Label>Android Icon (Material)</Label>
<Input
value={formData.icon_android}
onChange={(e) => setFormData({ ...formData, icon_android: e.target.value })}
placeholder="e.g., Build"
/>
</div>
</div>
<div className="space-y-2">
<Label>Tags (comma-separated)</Label>
<Input
value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
placeholder="e.g., hvac, filter, maintenance"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Display Order</Label>
<Input
type="number"
value={formData.display_order}
onChange={(e) => setFormData({ ...formData, display_order: parseInt(e.target.value) || 0 })}
/>
</div>
<div className="flex items-center gap-2 pt-6">
<Switch
checked={formData.is_active}
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
/>
<Label>Active</Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setIsCreating(false); setEditingTemplate(null); }}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!formData.title}>
{editingTemplate ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+2
View File
@@ -23,6 +23,7 @@ import {
Share2,
KeyRound,
Smartphone,
LayoutTemplate,
} from 'lucide-react';
import { useRouter, usePathname } from 'next/navigation';
import { useAuthStore } from '@/store/auth';
@@ -65,6 +66,7 @@ const limitationsItems = [
const settingsItems = [
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
{ title: 'Task Templates', url: '/admin/task-templates', icon: LayoutTemplate },
{ title: 'Admin Users', url: '/admin/admin-users', icon: UserCog },
{ title: 'Settings', url: '/admin/settings', icon: Settings },
];
+101
View File
@@ -1138,4 +1138,105 @@ export const promotionsApi = {
},
};
// Task Template Types
export interface TaskTemplateCategory {
id: number;
name: string;
description: string;
icon: string;
color: string;
display_order: number;
}
export interface TaskTemplateFrequency {
id: number;
name: string;
days: number;
display_order: number;
}
export interface TaskTemplate {
id: number;
title: string;
description: string;
category_id: number | null;
category?: TaskTemplateCategory;
frequency_id: number | null;
frequency?: TaskTemplateFrequency;
icon_ios: string;
icon_android: string;
tags: string;
display_order: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface TaskTemplateListParams {
page?: number;
per_page?: number;
search?: string;
sort_by?: string;
sort_dir?: 'asc' | 'desc';
category_id?: number;
frequency_id?: number;
is_active?: boolean;
}
export interface CreateTaskTemplateRequest {
title: string;
description?: string;
category_id?: number;
frequency_id?: number;
icon_ios?: string;
icon_android?: string;
tags?: string;
display_order?: number;
is_active?: boolean;
}
export interface UpdateTaskTemplateRequest {
title?: string;
description?: string;
category_id?: number | null;
frequency_id?: number | null;
icon_ios?: string;
icon_android?: string;
tags?: string;
display_order?: number;
is_active?: boolean;
}
// Task Templates API
export const taskTemplatesApi = {
list: async (params?: TaskTemplateListParams): Promise<PaginatedResponse<TaskTemplate>> => {
const response = await api.get<PaginatedResponse<TaskTemplate>>('/task-templates', { params });
return response.data;
},
get: async (id: number): Promise<TaskTemplate> => {
const response = await api.get<TaskTemplate>(`/task-templates/${id}`);
return response.data;
},
create: async (data: CreateTaskTemplateRequest): Promise<TaskTemplate> => {
const response = await api.post<TaskTemplate>('/task-templates', data);
return response.data;
},
update: async (id: number, data: UpdateTaskTemplateRequest): Promise<TaskTemplate> => {
const response = await api.put<TaskTemplate>(`/task-templates/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/task-templates/${id}`);
},
toggleActive: async (id: number): Promise<TaskTemplate> => {
const response = await api.post<TaskTemplate>(`/task-templates/${id}/toggle-active`);
return response.data;
},
};
export default api;