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:
@@ -74,7 +74,7 @@ export default function SettingsPage() {
|
|||||||
Seed Lookup Data
|
Seed Lookup Data
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Populate or refresh static lookup tables (categories, priorities, statuses, etc.)
|
Populate or refresh static lookup tables and task templates
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -97,6 +97,7 @@ export default function SettingsPage() {
|
|||||||
<li>Task categories, priorities, statuses, frequencies</li>
|
<li>Task categories, priorities, statuses, frequencies</li>
|
||||||
<li>Contractor specialties</li>
|
<li>Contractor specialties</li>
|
||||||
<li>Subscription tiers and feature benefits</li>
|
<li>Subscription tiers and feature benefits</li>
|
||||||
|
<li><strong>Task templates (60+ predefined tasks)</strong></li>
|
||||||
</ul>
|
</ul>
|
||||||
Existing data will be preserved or updated.
|
Existing data will be preserved or updated.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
|
|||||||
445
admin/src/app/(dashboard)/task-templates/page.tsx
Normal file
445
admin/src/app/(dashboard)/task-templates/page.tsx
Normal file
@@ -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 "Seed Lookup Data" 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 "{template.title}".</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Share2,
|
Share2,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
|
LayoutTemplate,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/store/auth';
|
import { useAuthStore } from '@/store/auth';
|
||||||
@@ -65,6 +66,7 @@ const limitationsItems = [
|
|||||||
|
|
||||||
const settingsItems = [
|
const settingsItems = [
|
||||||
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
|
{ 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: 'Admin Users', url: '/admin/admin-users', icon: UserCog },
|
||||||
{ title: 'Settings', url: '/admin/settings', icon: Settings },
|
{ title: 'Settings', url: '/admin/settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
export default api;
|
||||||
|
|||||||
151
docs/Dokku_notes
Normal file
151
docs/Dokku_notes
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
wget -NP . https://dokku.com/install/v0.36.10/bootstrap.sh
|
||||||
|
sudo DOKKU_TAG=v0.36.10 bash bootstrap.sh
|
||||||
|
|
||||||
|
# make sure authorized_keys has value first
|
||||||
|
cat ~/.ssh/authorized_keys | dokku ssh-keys:add admin
|
||||||
|
dokku domains:set-global 45.56.68.144
|
||||||
|
|
||||||
|
sudo dokku apps:create mycrib
|
||||||
|
sudo dokku plugin:install https://github.com/dokku/dokku-postgres.git
|
||||||
|
sudo dokku postgres:create mycrib
|
||||||
|
sudo dokku plugin:install https://github.com/dokku/dokku-redis.git redis
|
||||||
|
sudo dokku redis:create mycrib
|
||||||
|
|
||||||
|
sudo dokku postgres:link mycrib mycrib
|
||||||
|
sudo dokku redis:link mycrib mycrib
|
||||||
|
|
||||||
|
sudo dokku config:set --no-restart mycrib SECRET_KEY=wz%C*@^*wKZK9bV3CBMt7cj3wk2y^0vdu@2*pz7yco+p=7@sa%
|
||||||
|
|
||||||
|
dokku storage:mount mycrib /var/lib/dokku/data/storage/mycrib:/code/media/
|
||||||
|
|
||||||
|
# add remote dokku server as a remote repo in git
|
||||||
|
# this has to be done for git to be able to use the right key
|
||||||
|
Host mycribDev
|
||||||
|
HostName 104.200.20.223
|
||||||
|
User root
|
||||||
|
PreferredAuthentications publickey
|
||||||
|
IdentityFile /Users/treyt/.ssh/linode_88oakapps
|
||||||
|
IdentitiesOnly yes
|
||||||
|
AddKeysToAgent yes
|
||||||
|
|
||||||
|
|
||||||
|
git remote add dokku dokku@mycribDev:mycrib
|
||||||
|
|
||||||
|
# to push
|
||||||
|
git push dokku@mycribDev:mycrib develop
|
||||||
|
|
||||||
|
sudo dokku domains:add mycrib mycrib.treytartt.com
|
||||||
|
sudo dokku plugin:install https://github.com/dokku/dokku-letsencrypt.git
|
||||||
|
sudo dokku config:set --no-restart --global DOKKU_LETSENCRYPT_EMAIL=mycrib@treymail.com
|
||||||
|
sudo dokku letsencrypt:set mycrib email mycrib@treymail.com
|
||||||
|
sudo dokku letsencrypt:enable mycrib
|
||||||
|
sudo dokku letsencrypt:cron-job --add
|
||||||
|
sudo dokku plugin:install https://github.com/dokku/dokku-redirect.git
|
||||||
|
|
||||||
|
# media
|
||||||
|
dokku storage:ensure-directory mycrib
|
||||||
|
dokku storage:mount mycrib /var/lib/dokku/data/storage/mycrib/media:/code/media
|
||||||
|
|
||||||
|
|
||||||
|
# Make sure your settings.py has:
|
||||||
|
MEDIA_URL = '/media/'
|
||||||
|
MEDIA_ROOT = '/code/media/' # This matches the container path you mounted
|
||||||
|
|
||||||
|
dokku enter mycrib
|
||||||
|
chown -R dokku:dokku media # gives invalid user?
|
||||||
|
chmod -R 755 media
|
||||||
|
|
||||||
|
# make sure storage is listed
|
||||||
|
dokku storage:list mycrib
|
||||||
|
# list
|
||||||
|
/var/lib/dokku/data/storage/mycrib:/code/media/
|
||||||
|
|
||||||
|
# add location conf
|
||||||
|
nano /home/dokku/mycrib/nginx.conf.d/media.conf
|
||||||
|
|
||||||
|
# paste in
|
||||||
|
location /media/ {
|
||||||
|
alias /var/lib/dokku/data/storage/mycrib/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unmount storage (if needed)
|
||||||
|
dokku storage:unmount mycrib /var/lib/dokku/data/storage/mycrib:/app/media
|
||||||
|
|
||||||
|
# helpful commands
|
||||||
|
# restart app
|
||||||
|
dokku ps:restart mycrib
|
||||||
|
|
||||||
|
# enter docker container
|
||||||
|
dokku enter mycrib web
|
||||||
|
|
||||||
|
#check if debug
|
||||||
|
dokku run werkout python manage.py shell
|
||||||
|
from django.conf import settings
|
||||||
|
print(settings.DEBUG)
|
||||||
|
exit()
|
||||||
|
|
||||||
|
# clear out space
|
||||||
|
docker builder prune -a
|
||||||
|
docker system prune -a
|
||||||
|
|
||||||
|
# create super user
|
||||||
|
dokku run mycrib python manage.py createsuperuser
|
||||||
|
|
||||||
|
# postgres info
|
||||||
|
dokku postgres:info your-database-db
|
||||||
|
postgres://postgres:bf8f1cb443c35cd1ae0a58617ef348cd@dokku-postgres-your-database-db:5432/your_database_db
|
||||||
|
[database type]://[username]:[password]@[host]:[port]/[database name]
|
||||||
|
|
||||||
|
# show env
|
||||||
|
dokku config:show mycrib
|
||||||
|
|
||||||
|
# show how dokku is doing
|
||||||
|
dokku ps:report
|
||||||
|
|
||||||
|
# check workers are working
|
||||||
|
dokku ps:scale mycrib
|
||||||
|
|
||||||
|
# if not
|
||||||
|
dokku ps:scale mycrib web=1 worker=3 beat=1
|
||||||
|
|
||||||
|
|
||||||
|
# added this b/c claude told me to but doesnt look to be needed
|
||||||
|
dokku config:set mycrib \
|
||||||
|
SECURE_PROXY_SSL_HEADER="HTTP_X_FORWARDED_PROTO,https" \
|
||||||
|
USE_X_FORWARDED_HOST="True"
|
||||||
|
|
||||||
|
# dev smtp stuff
|
||||||
|
dokku config:set --no-restart mycrib \
|
||||||
|
EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend" \
|
||||||
|
EMAIL_HOST="smtp.fastmail.com" \
|
||||||
|
EMAIL_PORT="587" \
|
||||||
|
EMAIL_USE_TLS="True" \
|
||||||
|
EMAIL_HOST_USER="treytartt@fastmail.com" \
|
||||||
|
EMAIL_HOST_PASSWORD="2t9y4n4t497z5863" \
|
||||||
|
DEFAULT_FROM_EMAIL="MyCrib <crib@treymail.com>"
|
||||||
|
|
||||||
|
# for prod
|
||||||
|
dokku config:set mycrib \
|
||||||
|
APNS_AUTH_KEY_PATH="/code/push_certs/AuthKey_5PP558G7W7.p8" \
|
||||||
|
APNS_AUTH_KEY_ID="5PP558G7W7" \
|
||||||
|
APNS_TEAM_ID="V3PF3M6B6U" \
|
||||||
|
APNS_TOPIC="com.tt.mycrib.MyCribDev" \
|
||||||
|
APNS_USE_SANDBOX="False"
|
||||||
|
|
||||||
|
dokku config:set casera-api \
|
||||||
|
APNS_AUTH_KEY_PATH=/app/push_certs/AuthKey_9R5Q7ZX874.p8 \
|
||||||
|
APNS_AUTH_KEY_ID=9R5Q7ZX874 \
|
||||||
|
APNS_TEAM_ID=V3PF3M6B6U \
|
||||||
|
APNS_TOPIC=com.tt.casera.CaseraDev \
|
||||||
|
APNS_PRODUCTION=true
|
||||||
|
|
||||||
|
Production (mycribProdText):
|
||||||
|
dokku config:set mycrib GUNICORN_WORKERS=6 GUNICORN_THREADS=2
|
||||||
|
dokku ps:scale mycrib web=3
|
||||||
|
|
||||||
|
Development (mycribDev):
|
||||||
|
dokku config:set mycrib GUNICORN_WORKERS=4 GUNICORN_THREADS=1
|
||||||
|
dokku ps:scale mycrib web=1
|
||||||
|
|
||||||
|
# view worker logs
|
||||||
|
dokku logs mycrib worker -t
|
||||||
@@ -85,13 +85,21 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SeedLookups handles POST /api/admin/settings/seed-lookups
|
// SeedLookups handles POST /api/admin/settings/seed-lookups
|
||||||
|
// Seeds both lookup tables AND task templates
|
||||||
func (h *AdminSettingsHandler) SeedLookups(c *gin.Context) {
|
func (h *AdminSettingsHandler) SeedLookups(c *gin.Context) {
|
||||||
|
// First seed lookup tables
|
||||||
if err := h.runSeedFile("001_lookups.sql"); err != nil {
|
if err := h.runSeedFile("001_lookups.sql"); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed lookups: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed lookups: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Lookup data seeded successfully"})
|
// Then seed task templates
|
||||||
|
if err := h.runSeedFile("003_task_templates.sql"); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed task templates: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Lookup data and task templates seeded successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedTestData handles POST /api/admin/settings/seed-test-data
|
// SeedTestData handles POST /api/admin/settings/seed-test-data
|
||||||
@@ -104,6 +112,16 @@ func (h *AdminSettingsHandler) SeedTestData(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "Test data seeded successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "Test data seeded successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeedTaskTemplates handles POST /api/admin/settings/seed-task-templates
|
||||||
|
func (h *AdminSettingsHandler) SeedTaskTemplates(c *gin.Context) {
|
||||||
|
if err := h.runSeedFile("003_task_templates.sql"); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed task templates: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Task templates seeded successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
// runSeedFile executes a seed SQL file
|
// runSeedFile executes a seed SQL file
|
||||||
func (h *AdminSettingsHandler) runSeedFile(filename string) error {
|
func (h *AdminSettingsHandler) runSeedFile(filename string) error {
|
||||||
// Check multiple possible locations
|
// Check multiple possible locations
|
||||||
|
|||||||
321
internal/admin/handlers/task_template_handler.go
Normal file
321
internal/admin/handlers/task_template_handler.go
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminTaskTemplateHandler handles admin task template management endpoints
|
||||||
|
type AdminTaskTemplateHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminTaskTemplateHandler creates a new admin task template handler
|
||||||
|
func NewAdminTaskTemplateHandler(db *gorm.DB) *AdminTaskTemplateHandler {
|
||||||
|
return &AdminTaskTemplateHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskTemplateResponse represents a task template in admin responses
|
||||||
|
type TaskTemplateResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CategoryID *uint `json:"category_id"`
|
||||||
|
Category *TaskCategoryResponse `json:"category,omitempty"`
|
||||||
|
FrequencyID *uint `json:"frequency_id"`
|
||||||
|
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
|
||||||
|
IconIOS string `json:"icon_ios"`
|
||||||
|
IconAndroid string `json:"icon_android"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
DisplayOrder int `json:"display_order"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUpdateTaskTemplateRequest represents the request body for creating/updating templates
|
||||||
|
type CreateUpdateTaskTemplateRequest struct {
|
||||||
|
Title string `json:"title" binding:"required,max=200"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CategoryID *uint `json:"category_id"`
|
||||||
|
FrequencyID *uint `json:"frequency_id"`
|
||||||
|
IconIOS string `json:"icon_ios" binding:"max=100"`
|
||||||
|
IconAndroid string `json:"icon_android" binding:"max=100"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
DisplayOrder *int `json:"display_order"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTemplates handles GET /admin/api/task-templates/
|
||||||
|
func (h *AdminTaskTemplateHandler) ListTemplates(c *gin.Context) {
|
||||||
|
var templates []models.TaskTemplate
|
||||||
|
query := h.db.Preload("Category").Preload("Frequency").Order("display_order ASC, title ASC")
|
||||||
|
|
||||||
|
// Optional filter by active status
|
||||||
|
if activeParam := c.Query("is_active"); activeParam != "" {
|
||||||
|
isActive := activeParam == "true"
|
||||||
|
query = query.Where("is_active = ?", isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional filter by category
|
||||||
|
if categoryID := c.Query("category_id"); categoryID != "" {
|
||||||
|
query = query.Where("category_id = ?", categoryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional filter by frequency
|
||||||
|
if frequencyID := c.Query("frequency_id"); frequencyID != "" {
|
||||||
|
query = query.Where("frequency_id = ?", frequencyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional search
|
||||||
|
if search := c.Query("search"); search != "" {
|
||||||
|
searchTerm := "%" + strings.ToLower(search) + "%"
|
||||||
|
query = query.Where("LOWER(title) LIKE ? OR LOWER(tags) LIKE ?", searchTerm, searchTerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Find(&templates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]TaskTemplateResponse, len(templates))
|
||||||
|
for i, t := range templates {
|
||||||
|
responses[i] = h.toResponse(&t)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplate handles GET /admin/api/task-templates/:id/
|
||||||
|
func (h *AdminTaskTemplateHandler) GetTemplate(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var template models.TaskTemplate
|
||||||
|
if err := h.db.Preload("Category").Preload("Frequency").First(&template, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, h.toResponse(&template))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTemplate handles POST /admin/api/task-templates/
|
||||||
|
func (h *AdminTaskTemplateHandler) CreateTemplate(c *gin.Context) {
|
||||||
|
var req CreateUpdateTaskTemplateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template := models.TaskTemplate{
|
||||||
|
Title: req.Title,
|
||||||
|
Description: req.Description,
|
||||||
|
CategoryID: req.CategoryID,
|
||||||
|
FrequencyID: req.FrequencyID,
|
||||||
|
IconIOS: req.IconIOS,
|
||||||
|
IconAndroid: req.IconAndroid,
|
||||||
|
Tags: req.Tags,
|
||||||
|
IsActive: true, // Default to active
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DisplayOrder != nil {
|
||||||
|
template.DisplayOrder = *req.DisplayOrder
|
||||||
|
}
|
||||||
|
if req.IsActive != nil {
|
||||||
|
template.IsActive = *req.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Create(&template).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload with preloads
|
||||||
|
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, h.toResponse(&template))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTemplate handles PUT /admin/api/task-templates/:id/
|
||||||
|
func (h *AdminTaskTemplateHandler) UpdateTemplate(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var template models.TaskTemplate
|
||||||
|
if err := h.db.First(&template, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateUpdateTaskTemplateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template.Title = req.Title
|
||||||
|
template.Description = req.Description
|
||||||
|
template.CategoryID = req.CategoryID
|
||||||
|
template.FrequencyID = req.FrequencyID
|
||||||
|
template.IconIOS = req.IconIOS
|
||||||
|
template.IconAndroid = req.IconAndroid
|
||||||
|
template.Tags = req.Tags
|
||||||
|
|
||||||
|
if req.DisplayOrder != nil {
|
||||||
|
template.DisplayOrder = *req.DisplayOrder
|
||||||
|
}
|
||||||
|
if req.IsActive != nil {
|
||||||
|
template.IsActive = *req.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Save(&template).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload with preloads
|
||||||
|
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, h.toResponse(&template))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTemplate handles DELETE /admin/api/task-templates/:id/
|
||||||
|
func (h *AdminTaskTemplateHandler) DeleteTemplate(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Delete(&models.TaskTemplate{}, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Template deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleActive handles POST /admin/api/task-templates/:id/toggle-active/
|
||||||
|
func (h *AdminTaskTemplateHandler) ToggleActive(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var template models.TaskTemplate
|
||||||
|
if err := h.db.First(&template, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template.IsActive = !template.IsActive
|
||||||
|
if err := h.db.Save(&template).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload with preloads
|
||||||
|
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, h.toResponse(&template))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkCreate handles POST /admin/api/task-templates/bulk/
|
||||||
|
func (h *AdminTaskTemplateHandler) BulkCreate(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Templates []CreateUpdateTaskTemplateRequest `json:"templates" binding:"required,dive"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templates := make([]models.TaskTemplate, len(req.Templates))
|
||||||
|
for i, t := range req.Templates {
|
||||||
|
templates[i] = models.TaskTemplate{
|
||||||
|
Title: t.Title,
|
||||||
|
Description: t.Description,
|
||||||
|
CategoryID: t.CategoryID,
|
||||||
|
FrequencyID: t.FrequencyID,
|
||||||
|
IconIOS: t.IconIOS,
|
||||||
|
IconAndroid: t.IconAndroid,
|
||||||
|
Tags: t.Tags,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if t.DisplayOrder != nil {
|
||||||
|
templates[i].DisplayOrder = *t.DisplayOrder
|
||||||
|
}
|
||||||
|
if t.IsActive != nil {
|
||||||
|
templates[i].IsActive = *t.IsActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Create(&templates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create templates"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"message": "Templates created successfully", "count": len(templates)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert model to response
|
||||||
|
func (h *AdminTaskTemplateHandler) toResponse(t *models.TaskTemplate) TaskTemplateResponse {
|
||||||
|
resp := TaskTemplateResponse{
|
||||||
|
ID: t.ID,
|
||||||
|
Title: t.Title,
|
||||||
|
Description: t.Description,
|
||||||
|
CategoryID: t.CategoryID,
|
||||||
|
FrequencyID: t.FrequencyID,
|
||||||
|
IconIOS: t.IconIOS,
|
||||||
|
IconAndroid: t.IconAndroid,
|
||||||
|
Tags: t.Tags,
|
||||||
|
DisplayOrder: t.DisplayOrder,
|
||||||
|
IsActive: t.IsActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Category != nil {
|
||||||
|
resp.Category = &TaskCategoryResponse{
|
||||||
|
ID: t.Category.ID,
|
||||||
|
Name: t.Category.Name,
|
||||||
|
Description: t.Category.Description,
|
||||||
|
Icon: t.Category.Icon,
|
||||||
|
Color: t.Category.Color,
|
||||||
|
DisplayOrder: t.Category.DisplayOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Frequency != nil {
|
||||||
|
resp.Frequency = &TaskFrequencyResponse{
|
||||||
|
ID: t.Frequency.ID,
|
||||||
|
Name: t.Frequency.Name,
|
||||||
|
Days: t.Frequency.Days,
|
||||||
|
DisplayOrder: t.Frequency.DisplayOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
@@ -297,6 +297,19 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
|||||||
specialties.DELETE("/:id", lookupHandler.DeleteSpecialty)
|
specialties.DELETE("/:id", lookupHandler.DeleteSpecialty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task Templates management
|
||||||
|
taskTemplateHandler := handlers.NewAdminTaskTemplateHandler(db)
|
||||||
|
taskTemplates := protected.Group("/task-templates")
|
||||||
|
{
|
||||||
|
taskTemplates.GET("", taskTemplateHandler.ListTemplates)
|
||||||
|
taskTemplates.POST("", taskTemplateHandler.CreateTemplate)
|
||||||
|
taskTemplates.POST("/bulk", taskTemplateHandler.BulkCreate)
|
||||||
|
taskTemplates.GET("/:id", taskTemplateHandler.GetTemplate)
|
||||||
|
taskTemplates.PUT("/:id", taskTemplateHandler.UpdateTemplate)
|
||||||
|
taskTemplates.DELETE("/:id", taskTemplateHandler.DeleteTemplate)
|
||||||
|
taskTemplates.POST("/:id/toggle-active", taskTemplateHandler.ToggleActive)
|
||||||
|
}
|
||||||
|
|
||||||
// Admin user management
|
// Admin user management
|
||||||
adminUserHandler := handlers.NewAdminUserManagementHandler(db)
|
adminUserHandler := handlers.NewAdminUserManagementHandler(db)
|
||||||
adminUsers := protected.Group("/admin-users")
|
adminUsers := protected.Group("/admin-users")
|
||||||
@@ -327,6 +340,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
|||||||
settings.PUT("", settingsHandler.UpdateSettings)
|
settings.PUT("", settingsHandler.UpdateSettings)
|
||||||
settings.POST("/seed-lookups", settingsHandler.SeedLookups)
|
settings.POST("/seed-lookups", settingsHandler.SeedLookups)
|
||||||
settings.POST("/seed-test-data", settingsHandler.SeedTestData)
|
settings.POST("/seed-test-data", settingsHandler.SeedTestData)
|
||||||
|
settings.POST("/seed-task-templates", settingsHandler.SeedTaskTemplates)
|
||||||
settings.POST("/clear-all-data", settingsHandler.ClearAllData)
|
settings.POST("/clear-all-data", settingsHandler.ClearAllData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ func Migrate() error {
|
|||||||
&models.TaskFrequency{},
|
&models.TaskFrequency{},
|
||||||
&models.TaskStatus{},
|
&models.TaskStatus{},
|
||||||
&models.ContractorSpecialty{},
|
&models.ContractorSpecialty{},
|
||||||
|
&models.TaskTemplate{}, // Task templates reference category and frequency
|
||||||
|
|
||||||
// User and auth tables
|
// User and auth tables
|
||||||
&models.User{},
|
&models.User{},
|
||||||
|
|||||||
134
internal/dto/responses/task_template.go
Normal file
134
internal/dto/responses/task_template.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package responses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskTemplateResponse represents a task template in the API response
|
||||||
|
type TaskTemplateResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CategoryID *uint `json:"category_id"`
|
||||||
|
Category *TaskCategoryResponse `json:"category,omitempty"`
|
||||||
|
FrequencyID *uint `json:"frequency_id"`
|
||||||
|
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
|
||||||
|
IconIOS string `json:"icon_ios"`
|
||||||
|
IconAndroid string `json:"icon_android"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
DisplayOrder int `json:"display_order"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskTemplateCategoryGroup represents templates grouped by category
|
||||||
|
type TaskTemplateCategoryGroup struct {
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
CategoryID *uint `json:"category_id"`
|
||||||
|
Templates []TaskTemplateResponse `json:"templates"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskTemplatesGroupedResponse represents all templates grouped by category
|
||||||
|
type TaskTemplatesGroupedResponse struct {
|
||||||
|
Categories []TaskTemplateCategoryGroup `json:"categories"`
|
||||||
|
TotalCount int `json:"total_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskTemplateResponse creates a TaskTemplateResponse from a model
|
||||||
|
func NewTaskTemplateResponse(t *models.TaskTemplate) TaskTemplateResponse {
|
||||||
|
resp := TaskTemplateResponse{
|
||||||
|
ID: t.ID,
|
||||||
|
Title: t.Title,
|
||||||
|
Description: t.Description,
|
||||||
|
CategoryID: t.CategoryID,
|
||||||
|
FrequencyID: t.FrequencyID,
|
||||||
|
IconIOS: t.IconIOS,
|
||||||
|
IconAndroid: t.IconAndroid,
|
||||||
|
Tags: parseTags(t.Tags),
|
||||||
|
DisplayOrder: t.DisplayOrder,
|
||||||
|
IsActive: t.IsActive,
|
||||||
|
CreatedAt: t.CreatedAt,
|
||||||
|
UpdatedAt: t.UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Category != nil {
|
||||||
|
resp.Category = NewTaskCategoryResponse(t.Category)
|
||||||
|
}
|
||||||
|
if t.Frequency != nil {
|
||||||
|
resp.Frequency = NewTaskFrequencyResponse(t.Frequency)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskTemplateListResponse creates a list of task template responses
|
||||||
|
func NewTaskTemplateListResponse(templates []models.TaskTemplate) []TaskTemplateResponse {
|
||||||
|
results := make([]TaskTemplateResponse, len(templates))
|
||||||
|
for i, t := range templates {
|
||||||
|
results[i] = NewTaskTemplateResponse(&t)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskTemplatesGroupedResponse creates a grouped response from templates
|
||||||
|
func NewTaskTemplatesGroupedResponse(templates []models.TaskTemplate) TaskTemplatesGroupedResponse {
|
||||||
|
// Group by category
|
||||||
|
categoryMap := make(map[string]*TaskTemplateCategoryGroup)
|
||||||
|
categoryOrder := []string{} // To maintain order
|
||||||
|
|
||||||
|
for _, t := range templates {
|
||||||
|
categoryName := "Uncategorized"
|
||||||
|
var categoryID *uint
|
||||||
|
if t.Category != nil {
|
||||||
|
categoryName = t.Category.Name
|
||||||
|
categoryID = &t.Category.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := categoryMap[categoryName]; !exists {
|
||||||
|
categoryMap[categoryName] = &TaskTemplateCategoryGroup{
|
||||||
|
CategoryName: categoryName,
|
||||||
|
CategoryID: categoryID,
|
||||||
|
Templates: []TaskTemplateResponse{},
|
||||||
|
}
|
||||||
|
categoryOrder = append(categoryOrder, categoryName)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryMap[categoryName].Templates = append(categoryMap[categoryName].Templates, NewTaskTemplateResponse(&t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ordered result
|
||||||
|
categories := make([]TaskTemplateCategoryGroup, len(categoryOrder))
|
||||||
|
totalCount := 0
|
||||||
|
for i, name := range categoryOrder {
|
||||||
|
group := categoryMap[name]
|
||||||
|
group.Count = len(group.Templates)
|
||||||
|
totalCount += group.Count
|
||||||
|
categories[i] = *group
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskTemplatesGroupedResponse{
|
||||||
|
Categories: categories,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTags splits a comma-separated tags string into a slice
|
||||||
|
func parseTags(tags string) []string {
|
||||||
|
if tags == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
parts := strings.Split(tags, ",")
|
||||||
|
result := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
trimmed := strings.TrimSpace(p)
|
||||||
|
if trimmed != "" {
|
||||||
|
result = append(result, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
106
internal/handlers/task_template_handler.go
Normal file
106
internal/handlers/task_template_handler.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/treytartt/casera-api/internal/i18n"
|
||||||
|
"github.com/treytartt/casera-api/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskTemplateHandler handles task template endpoints
|
||||||
|
type TaskTemplateHandler struct {
|
||||||
|
templateService *services.TaskTemplateService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskTemplateHandler creates a new task template handler
|
||||||
|
func NewTaskTemplateHandler(templateService *services.TaskTemplateService) *TaskTemplateHandler {
|
||||||
|
return &TaskTemplateHandler{
|
||||||
|
templateService: templateService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplates handles GET /api/tasks/templates/
|
||||||
|
// Returns all active task templates as a flat list
|
||||||
|
func (h *TaskTemplateHandler) GetTemplates(c *gin.Context) {
|
||||||
|
templates, err := h.templateService.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, templates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplatesGrouped handles GET /api/tasks/templates/grouped/
|
||||||
|
// Returns all templates grouped by category
|
||||||
|
func (h *TaskTemplateHandler) GetTemplatesGrouped(c *gin.Context) {
|
||||||
|
grouped, err := h.templateService.GetGrouped()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTemplates handles GET /api/tasks/templates/search/
|
||||||
|
// Searches templates by query string
|
||||||
|
func (h *TaskTemplateHandler) SearchTemplates(c *gin.Context) {
|
||||||
|
query := c.Query("q")
|
||||||
|
if query == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(query) < 2 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Query must be at least 2 characters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templates, err := h.templateService.Search(query)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_search_templates")})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, templates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplatesByCategory handles GET /api/tasks/templates/by-category/:category_id/
|
||||||
|
// Returns templates for a specific category
|
||||||
|
func (h *TaskTemplateHandler) GetTemplatesByCategory(c *gin.Context) {
|
||||||
|
categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templates, err := h.templateService.GetByCategory(uint(categoryID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, templates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplate handles GET /api/tasks/templates/:id/
|
||||||
|
// Returns a single template by ID
|
||||||
|
func (h *TaskTemplateHandler) GetTemplate(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template, err := h.templateService.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.template_not_found")})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, template)
|
||||||
|
}
|
||||||
@@ -114,6 +114,9 @@
|
|||||||
"error.failed_to_fetch_task_frequencies": "Failed to fetch task frequencies",
|
"error.failed_to_fetch_task_frequencies": "Failed to fetch task frequencies",
|
||||||
"error.failed_to_fetch_task_statuses": "Failed to fetch task statuses",
|
"error.failed_to_fetch_task_statuses": "Failed to fetch task statuses",
|
||||||
"error.failed_to_fetch_contractor_specialties": "Failed to fetch contractor specialties",
|
"error.failed_to_fetch_contractor_specialties": "Failed to fetch contractor specialties",
|
||||||
|
"error.failed_to_fetch_templates": "Failed to fetch task templates",
|
||||||
|
"error.failed_to_search_templates": "Failed to search task templates",
|
||||||
|
"error.template_not_found": "Task template not found",
|
||||||
|
|
||||||
"push.task_due_soon.title": "Task Due Soon",
|
"push.task_due_soon.title": "Task Due Soon",
|
||||||
"push.task_due_soon.body": "{{.TaskTitle}} is due {{.DueDate}}",
|
"push.task_due_soon.body": "{{.TaskTitle}} is due {{.DueDate}}",
|
||||||
|
|||||||
22
internal/models/task_template.go
Normal file
22
internal/models/task_template.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// TaskTemplate represents a predefined task template that users can select when creating tasks
|
||||||
|
type TaskTemplate struct {
|
||||||
|
BaseModel
|
||||||
|
Title string `gorm:"column:title;size:200;not null" json:"title"`
|
||||||
|
Description string `gorm:"column:description;type:text" json:"description"`
|
||||||
|
CategoryID *uint `gorm:"column:category_id;index" json:"category_id"`
|
||||||
|
Category *TaskCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||||
|
FrequencyID *uint `gorm:"column:frequency_id;index" json:"frequency_id"`
|
||||||
|
Frequency *TaskFrequency `gorm:"foreignKey:FrequencyID" json:"frequency,omitempty"`
|
||||||
|
IconIOS string `gorm:"column:icon_ios;size:100" json:"icon_ios"`
|
||||||
|
IconAndroid string `gorm:"column:icon_android;size:100" json:"icon_android"`
|
||||||
|
Tags string `gorm:"column:tags;type:text" json:"tags"` // Comma-separated tags for search
|
||||||
|
DisplayOrder int `gorm:"column:display_order;default:0" json:"display_order"`
|
||||||
|
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for GORM
|
||||||
|
func (TaskTemplate) TableName() string {
|
||||||
|
return "task_tasktemplate"
|
||||||
|
}
|
||||||
123
internal/repositories/task_template_repo.go
Normal file
123
internal/repositories/task_template_repo.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskTemplateRepository handles database operations for task templates
|
||||||
|
type TaskTemplateRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskTemplateRepository creates a new task template repository
|
||||||
|
func NewTaskTemplateRepository(db *gorm.DB) *TaskTemplateRepository {
|
||||||
|
return &TaskTemplateRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns all active task templates ordered by category and display order
|
||||||
|
func (r *TaskTemplateRepository) GetAll() ([]models.TaskTemplate, error) {
|
||||||
|
var templates []models.TaskTemplate
|
||||||
|
err := r.db.
|
||||||
|
Preload("Category").
|
||||||
|
Preload("Frequency").
|
||||||
|
Where("is_active = ?", true).
|
||||||
|
Order("display_order ASC, title ASC").
|
||||||
|
Find(&templates).Error
|
||||||
|
return templates, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByCategory returns all active templates for a specific category
|
||||||
|
func (r *TaskTemplateRepository) GetByCategory(categoryID uint) ([]models.TaskTemplate, error) {
|
||||||
|
var templates []models.TaskTemplate
|
||||||
|
err := r.db.
|
||||||
|
Preload("Category").
|
||||||
|
Preload("Frequency").
|
||||||
|
Where("is_active = ? AND category_id = ?", true, categoryID).
|
||||||
|
Order("display_order ASC, title ASC").
|
||||||
|
Find(&templates).Error
|
||||||
|
return templates, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search searches templates by title and tags
|
||||||
|
func (r *TaskTemplateRepository) Search(query string) ([]models.TaskTemplate, error) {
|
||||||
|
var templates []models.TaskTemplate
|
||||||
|
searchTerm := "%" + strings.ToLower(query) + "%"
|
||||||
|
|
||||||
|
err := r.db.
|
||||||
|
Preload("Category").
|
||||||
|
Preload("Frequency").
|
||||||
|
Where("is_active = ? AND (LOWER(title) LIKE ? OR LOWER(tags) LIKE ?)", true, searchTerm, searchTerm).
|
||||||
|
Order("display_order ASC, title ASC").
|
||||||
|
Limit(20). // Limit search results
|
||||||
|
Find(&templates).Error
|
||||||
|
return templates, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns a single template by ID
|
||||||
|
func (r *TaskTemplateRepository) GetByID(id uint) (*models.TaskTemplate, error) {
|
||||||
|
var template models.TaskTemplate
|
||||||
|
err := r.db.
|
||||||
|
Preload("Category").
|
||||||
|
Preload("Frequency").
|
||||||
|
First(&template, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &template, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new task template
|
||||||
|
func (r *TaskTemplateRepository) Create(template *models.TaskTemplate) error {
|
||||||
|
return r.db.Create(template).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates an existing task template
|
||||||
|
func (r *TaskTemplateRepository) Update(template *models.TaskTemplate) error {
|
||||||
|
return r.db.Save(template).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete hard deletes a task template
|
||||||
|
func (r *TaskTemplateRepository) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.TaskTemplate{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllIncludingInactive returns all templates including inactive ones (for admin)
|
||||||
|
func (r *TaskTemplateRepository) GetAllIncludingInactive() ([]models.TaskTemplate, error) {
|
||||||
|
var templates []models.TaskTemplate
|
||||||
|
err := r.db.
|
||||||
|
Preload("Category").
|
||||||
|
Preload("Frequency").
|
||||||
|
Order("display_order ASC, title ASC").
|
||||||
|
Find(&templates).Error
|
||||||
|
return templates, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the total count of active templates
|
||||||
|
func (r *TaskTemplateRepository) Count() (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.TaskTemplate{}).Where("is_active = ?", true).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupedByCategory returns templates grouped by category name
|
||||||
|
func (r *TaskTemplateRepository) GetGroupedByCategory() (map[string][]models.TaskTemplate, error) {
|
||||||
|
templates, err := r.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string][]models.TaskTemplate)
|
||||||
|
for _, t := range templates {
|
||||||
|
categoryName := "Uncategorized"
|
||||||
|
if t.Category != nil {
|
||||||
|
categoryName = t.Category.Name
|
||||||
|
}
|
||||||
|
result[categoryName] = append(result[categoryName], t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
|||||||
documentRepo := repositories.NewDocumentRepository(deps.DB)
|
documentRepo := repositories.NewDocumentRepository(deps.DB)
|
||||||
notificationRepo := repositories.NewNotificationRepository(deps.DB)
|
notificationRepo := repositories.NewNotificationRepository(deps.DB)
|
||||||
subscriptionRepo := repositories.NewSubscriptionRepository(deps.DB)
|
subscriptionRepo := repositories.NewSubscriptionRepository(deps.DB)
|
||||||
|
taskTemplateRepo := repositories.NewTaskTemplateRepository(deps.DB)
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
authService := services.NewAuthService(userRepo, cfg)
|
authService := services.NewAuthService(userRepo, cfg)
|
||||||
@@ -99,6 +100,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
|||||||
taskService.SetNotificationService(notificationService)
|
taskService.SetNotificationService(notificationService)
|
||||||
taskService.SetEmailService(deps.EmailService)
|
taskService.SetEmailService(deps.EmailService)
|
||||||
subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
|
subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
|
||||||
|
taskTemplateService := services.NewTaskTemplateService(taskTemplateRepo)
|
||||||
|
|
||||||
// Initialize middleware
|
// Initialize middleware
|
||||||
authMiddleware := middleware.NewAuthMiddleware(deps.DB, deps.Cache)
|
authMiddleware := middleware.NewAuthMiddleware(deps.DB, deps.Cache)
|
||||||
@@ -117,6 +119,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
|||||||
notificationHandler := handlers.NewNotificationHandler(notificationService)
|
notificationHandler := handlers.NewNotificationHandler(notificationService)
|
||||||
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService)
|
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService)
|
||||||
staticDataHandler := handlers.NewStaticDataHandler(residenceService, taskService, contractorService)
|
staticDataHandler := handlers.NewStaticDataHandler(residenceService, taskService, contractorService)
|
||||||
|
taskTemplateHandler := handlers.NewTaskTemplateHandler(taskTemplateService)
|
||||||
|
|
||||||
// Initialize upload handler (if storage service is available)
|
// Initialize upload handler (if storage service is available)
|
||||||
var uploadHandler *handlers.UploadHandler
|
var uploadHandler *handlers.UploadHandler
|
||||||
@@ -140,7 +143,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
|||||||
setupPublicAuthRoutes(api, authHandler)
|
setupPublicAuthRoutes(api, authHandler)
|
||||||
|
|
||||||
// Public data routes (no auth required)
|
// Public data routes (no auth required)
|
||||||
setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler)
|
setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler, taskTemplateHandler)
|
||||||
|
|
||||||
// Protected routes (auth required)
|
// Protected routes (auth required)
|
||||||
protected := api.Group("")
|
protected := api.Group("")
|
||||||
@@ -220,7 +223,7 @@ func setupProtectedAuthRoutes(api *gin.RouterGroup, authHandler *handlers.AuthHa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// setupPublicDataRoutes configures public data routes (lookups, static data)
|
// setupPublicDataRoutes configures public data routes (lookups, static data)
|
||||||
func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.ResidenceHandler, taskHandler *handlers.TaskHandler, contractorHandler *handlers.ContractorHandler, staticDataHandler *handlers.StaticDataHandler, subscriptionHandler *handlers.SubscriptionHandler) {
|
func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.ResidenceHandler, taskHandler *handlers.TaskHandler, contractorHandler *handlers.ContractorHandler, staticDataHandler *handlers.StaticDataHandler, subscriptionHandler *handlers.SubscriptionHandler, taskTemplateHandler *handlers.TaskTemplateHandler) {
|
||||||
// Static data routes (public, cached)
|
// Static data routes (public, cached)
|
||||||
staticData := api.Group("/static_data")
|
staticData := api.Group("/static_data")
|
||||||
{
|
{
|
||||||
@@ -241,6 +244,16 @@ func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.Resi
|
|||||||
api.GET("/tasks/frequencies/", taskHandler.GetFrequencies)
|
api.GET("/tasks/frequencies/", taskHandler.GetFrequencies)
|
||||||
api.GET("/tasks/statuses/", taskHandler.GetStatuses)
|
api.GET("/tasks/statuses/", taskHandler.GetStatuses)
|
||||||
api.GET("/contractors/specialties/", contractorHandler.GetSpecialties)
|
api.GET("/contractors/specialties/", contractorHandler.GetSpecialties)
|
||||||
|
|
||||||
|
// Task template routes (public, for app autocomplete)
|
||||||
|
templates := api.Group("/tasks/templates")
|
||||||
|
{
|
||||||
|
templates.GET("/", taskTemplateHandler.GetTemplates)
|
||||||
|
templates.GET("/grouped/", taskTemplateHandler.GetTemplatesGrouped)
|
||||||
|
templates.GET("/search/", taskTemplateHandler.SearchTemplates)
|
||||||
|
templates.GET("/by-category/:category_id/", taskTemplateHandler.GetTemplatesByCategory)
|
||||||
|
templates.GET("/:id/", taskTemplateHandler.GetTemplate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupResidenceRoutes configures residence routes
|
// setupResidenceRoutes configures residence routes
|
||||||
|
|||||||
69
internal/services/task_template_service.go
Normal file
69
internal/services/task_template_service.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/treytartt/casera-api/internal/dto/responses"
|
||||||
|
"github.com/treytartt/casera-api/internal/repositories"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskTemplateService handles business logic for task templates
|
||||||
|
type TaskTemplateService struct {
|
||||||
|
templateRepo *repositories.TaskTemplateRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskTemplateService creates a new task template service
|
||||||
|
func NewTaskTemplateService(templateRepo *repositories.TaskTemplateRepository) *TaskTemplateService {
|
||||||
|
return &TaskTemplateService{
|
||||||
|
templateRepo: templateRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns all active task templates
|
||||||
|
func (s *TaskTemplateService) GetAll() ([]responses.TaskTemplateResponse, error) {
|
||||||
|
templates, err := s.templateRepo.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return responses.NewTaskTemplateListResponse(templates), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGrouped returns all templates grouped by category
|
||||||
|
func (s *TaskTemplateService) GetGrouped() (responses.TaskTemplatesGroupedResponse, error) {
|
||||||
|
templates, err := s.templateRepo.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
return responses.TaskTemplatesGroupedResponse{}, err
|
||||||
|
}
|
||||||
|
return responses.NewTaskTemplatesGroupedResponse(templates), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search searches templates by query string
|
||||||
|
func (s *TaskTemplateService) Search(query string) ([]responses.TaskTemplateResponse, error) {
|
||||||
|
templates, err := s.templateRepo.Search(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return responses.NewTaskTemplateListResponse(templates), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByCategory returns templates for a specific category
|
||||||
|
func (s *TaskTemplateService) GetByCategory(categoryID uint) ([]responses.TaskTemplateResponse, error) {
|
||||||
|
templates, err := s.templateRepo.GetByCategory(categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return responses.NewTaskTemplateListResponse(templates), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns a single template by ID
|
||||||
|
func (s *TaskTemplateService) GetByID(id uint) (*responses.TaskTemplateResponse, error) {
|
||||||
|
template, err := s.templateRepo.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := responses.NewTaskTemplateResponse(template)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the total count of active templates
|
||||||
|
func (s *TaskTemplateService) Count() (int64, error) {
|
||||||
|
return s.templateRepo.Count()
|
||||||
|
}
|
||||||
106
seeds/003_task_templates.sql
Normal file
106
seeds/003_task_templates.sql
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
-- Seed task templates for MyCrib
|
||||||
|
-- Run with: ./dev.sh seed (after running 001_lookups.sql)
|
||||||
|
--
|
||||||
|
-- Category IDs (from 001_lookups.sql):
|
||||||
|
-- 1 = Appliances, 2 = Cleaning, 3 = Electrical, 4 = Exterior
|
||||||
|
-- 5 = General, 6 = HVAC, 7 = Interior, 8 = Pest Control, 9 = Plumbing, 10 = Safety
|
||||||
|
--
|
||||||
|
-- Frequency IDs (from 001_lookups.sql):
|
||||||
|
-- 1 = Once, 2 = Daily, 3 = Weekly, 4 = Bi-Weekly, 5 = Monthly
|
||||||
|
-- 6 = Quarterly, 7 = Semi-Annually, 8 = Annually
|
||||||
|
|
||||||
|
-- Task Templates
|
||||||
|
INSERT INTO task_tasktemplate (id, created_at, updated_at, title, description, category_id, frequency_id, icon_ios, icon_android, tags, display_order, is_active)
|
||||||
|
VALUES
|
||||||
|
-- PLUMBING (category_id = 9)
|
||||||
|
(1, NOW(), NOW(), 'Check/Replace Water Heater Anode Rod', 'Inspect anode rod for corrosion and replace if more than 50% depleted', 9, 8, 'wrench.and.screwdriver.fill', 'Build', 'water heater,anode,corrosion,tank', 1, true),
|
||||||
|
(2, NOW(), NOW(), 'Test Interior Water Shutoffs', 'Turn each shutoff valve to ensure it works properly', 9, 8, 'spigot.fill', 'Water', 'shutoff,valve,water,emergency', 2, true),
|
||||||
|
(3, NOW(), NOW(), 'Test Water Meter Shutoff', 'Ensure main water shutoff at meter is functional', 9, 8, 'gauge.with.dots.needle.33percent', 'Speed', 'meter,shutoff,main,emergency', 3, true),
|
||||||
|
(4, NOW(), NOW(), 'Check Water Meter for Leaks', 'Turn off all water and check if meter is still moving', 9, 5, 'drop.triangle.fill', 'WaterDrop', 'leak,meter,water,detection', 4, true),
|
||||||
|
(5, NOW(), NOW(), 'Run Drain Cleaner', 'Use enzyme-based drain cleaner to prevent clogs', 9, 5, 'arrow.down.to.line.circle.fill', 'VerticalAlignBottom', 'drain,clog,cleaner,pipes', 5, true),
|
||||||
|
(6, NOW(), NOW(), 'Test Water Heater Pressure Relief Valve', 'Lift lever to release some water and ensure valve works', 9, 8, 'bolt.horizontal.fill', 'Compress', 'water heater,pressure,safety,valve', 6, true),
|
||||||
|
(7, NOW(), NOW(), 'Replace Water Filters', 'Replace whole-house or point-of-use water filters', 9, 6, 'drop.fill', 'Water', 'filter,water,replacement,drinking', 7, true),
|
||||||
|
|
||||||
|
-- SAFETY (category_id = 10)
|
||||||
|
(8, NOW(), NOW(), 'Test Smoke Detectors', 'Press test button on each detector and replace batteries if needed', 10, 5, 'smoke.fill', 'Sensors', 'smoke,detector,safety,fire', 8, true),
|
||||||
|
(9, NOW(), NOW(), 'Test Carbon Monoxide Detectors', 'Press test button and verify alarm sounds', 10, 5, 'sensor.tag.radiowaves.forward.fill', 'Air', 'carbon monoxide,detector,safety,gas', 9, true),
|
||||||
|
(10, NOW(), NOW(), 'Inspect Fire Extinguisher', 'Check pressure gauge and ensure pin is intact', 10, 8, 'flame.fill', 'LocalFireDepartment', 'fire,extinguisher,safety,inspection', 10, true),
|
||||||
|
(11, NOW(), NOW(), 'Replace Smoke Detector Batteries', 'Replace batteries in all smoke and CO detectors', 10, 8, 'battery.100', 'Battery5Bar', 'battery,smoke,detector,safety', 11, true),
|
||||||
|
(12, NOW(), NOW(), 'Test GFCI Outlets', 'Press test/reset buttons on all GFCI outlets', 10, 5, 'poweroutlet.type.b', 'ElectricalServices', 'gfci,outlet,safety,electrical', 12, true),
|
||||||
|
(13, NOW(), NOW(), 'Check Emergency Exits', 'Verify all exits are clear and doors open freely', 10, 6, 'door.left.hand.open', 'DoorFront', 'emergency,exit,safety,door', 13, true),
|
||||||
|
|
||||||
|
-- HVAC (category_id = 6)
|
||||||
|
(14, NOW(), NOW(), 'Replace HVAC Filter', 'Replace air filter to maintain air quality and efficiency', 6, 5, 'air.purifier', 'Air', 'hvac,filter,air,replacement', 14, true),
|
||||||
|
(15, NOW(), NOW(), 'Clean AC Condensate Drain', 'Clear drain line to prevent clogs and water damage', 6, 6, 'drop.degreesign.fill', 'WaterDamage', 'ac,condensate,drain,clog', 15, true),
|
||||||
|
(16, NOW(), NOW(), 'Schedule HVAC Service', 'Annual professional maintenance for heating and cooling system', 6, 8, 'wrench.and.screwdriver', 'Build', 'hvac,service,maintenance,professional', 16, true),
|
||||||
|
(17, NOW(), NOW(), 'Clean Air Vents and Registers', 'Remove dust and debris from all vents', 6, 6, 'wind', 'AirPurifier', 'vent,register,dust,cleaning', 17, true),
|
||||||
|
(18, NOW(), NOW(), 'Test Thermostat', 'Verify thermostat accurately controls heating and cooling', 6, 7, 'thermometer.sun.fill', 'Thermostat', 'thermostat,hvac,test,calibration', 18, true),
|
||||||
|
(19, NOW(), NOW(), 'Inspect Ductwork', 'Check for leaks, damage, or disconnected sections', 6, 8, 'rectangle.3.group', 'Hvac', 'duct,ductwork,leak,inspection', 19, true),
|
||||||
|
(20, NOW(), NOW(), 'Clean Outdoor AC Unit', 'Remove debris and clean condenser coils', 6, 8, 'fan', 'WindPower', 'ac,condenser,outdoor,cleaning', 20, true),
|
||||||
|
(21, NOW(), NOW(), 'Check Refrigerant Levels', 'Have professional check AC refrigerant', 6, 8, 'snowflake', 'AcUnit', 'refrigerant,ac,freon,professional', 21, true),
|
||||||
|
(22, NOW(), NOW(), 'Inspect Heat Pump', 'Check heat pump operation and clean coils', 6, 7, 'heat.waves', 'HeatPump', 'heat pump,inspection,coils,maintenance', 22, true),
|
||||||
|
|
||||||
|
-- APPLIANCES (category_id = 1)
|
||||||
|
(23, NOW(), NOW(), 'Clean Refrigerator Coils', 'Vacuum condenser coils to improve efficiency', 1, 7, 'refrigerator.fill', 'Kitchen', 'refrigerator,coils,cleaning,efficiency', 23, true),
|
||||||
|
(24, NOW(), NOW(), 'Clean Dishwasher Filter', 'Remove and clean the dishwasher filter', 1, 5, 'dishwasher.fill', 'LocalLaundryService', 'dishwasher,filter,cleaning', 24, true),
|
||||||
|
(25, NOW(), NOW(), 'Clean Washing Machine', 'Run cleaning cycle or use washing machine cleaner', 1, 5, 'washer.fill', 'LocalLaundryService', 'washing machine,cleaning,maintenance', 25, true),
|
||||||
|
(26, NOW(), NOW(), 'Clean Dryer Vent', 'Clean lint from dryer vent and ductwork', 1, 8, 'dryer.fill', 'LocalLaundryService', 'dryer,vent,lint,fire hazard', 26, true),
|
||||||
|
(27, NOW(), NOW(), 'Clean Range Hood Filter', 'Remove and clean grease filter', 1, 6, 'stove.fill', 'Microwave', 'range hood,filter,grease,cleaning', 27, true),
|
||||||
|
(28, NOW(), NOW(), 'Descale Coffee Maker', 'Run descaling solution through coffee maker', 1, 5, 'cup.and.saucer.fill', 'Coffee', 'coffee,descale,cleaning,appliance', 28, true),
|
||||||
|
(29, NOW(), NOW(), 'Clean Garbage Disposal', 'Clean and deodorize garbage disposal', 1, 5, 'trash.fill', 'Delete', 'garbage disposal,cleaning,deodorize', 29, true),
|
||||||
|
(30, NOW(), NOW(), 'Clean Oven', 'Run self-clean cycle or manually clean oven', 1, 6, 'oven.fill', 'Microwave', 'oven,cleaning,grease', 30, true),
|
||||||
|
(31, NOW(), NOW(), 'Check Refrigerator Seals', 'Inspect door gaskets for cracks or gaps', 1, 7, 'seal.fill', 'DoorSliding', 'refrigerator,seal,gasket,inspection', 31, true),
|
||||||
|
|
||||||
|
-- EXTERIOR (category_id = 4)
|
||||||
|
(32, NOW(), NOW(), 'Clean Gutters', 'Remove leaves and debris from gutters and downspouts', 4, 7, 'house.fill', 'Roofing', 'gutter,cleaning,leaves,debris', 32, true),
|
||||||
|
(33, NOW(), NOW(), 'Inspect Roof', 'Check for damaged, missing, or loose shingles', 4, 8, 'house.circle.fill', 'Roofing', 'roof,shingles,inspection,damage', 33, true),
|
||||||
|
(34, NOW(), NOW(), 'Power Wash Exterior', 'Clean siding, walkways, and driveway', 4, 8, 'water.waves', 'CleaningServices', 'power wash,siding,driveway,cleaning', 34, true),
|
||||||
|
(35, NOW(), NOW(), 'Seal Driveway', 'Apply sealant to asphalt or concrete driveway', 4, 8, 'road.lanes', 'RoundaboutRight', 'driveway,seal,asphalt,concrete', 35, true),
|
||||||
|
(36, NOW(), NOW(), 'Inspect Foundation', 'Check for cracks or signs of settling', 4, 8, 'building.2.fill', 'Foundation', 'foundation,cracks,inspection,settling', 36, true),
|
||||||
|
(37, NOW(), NOW(), 'Clean Window Exteriors', 'Wash exterior window surfaces', 4, 7, 'window.horizontal', 'Window', 'window,exterior,cleaning,glass', 37, true),
|
||||||
|
(38, NOW(), NOW(), 'Inspect Deck/Patio', 'Check for rot, loose boards, or damage', 4, 8, 'rectangle.split.3x1.fill', 'Deck', 'deck,patio,inspection,rot', 38, true),
|
||||||
|
(39, NOW(), NOW(), 'Stain/Seal Deck', 'Apply stain or sealant to protect wood deck', 4, 8, 'paintbrush.fill', 'FormatPaint', 'deck,stain,seal,wood', 39, true),
|
||||||
|
|
||||||
|
-- LAWN & GARDEN (category_id = 4, but could be General category = 5)
|
||||||
|
(40, NOW(), NOW(), 'Mow Lawn', 'Cut grass to appropriate height', 4, 3, 'leaf.fill', 'Grass', 'lawn,mowing,grass,yard', 40, true),
|
||||||
|
(41, NOW(), NOW(), 'Trim Trees/Shrubs', 'Prune overgrown branches and shape shrubs', 4, 8, 'tree.fill', 'Park', 'tree,shrub,pruning,trimming', 41, true),
|
||||||
|
(42, NOW(), NOW(), 'Fertilize Lawn', 'Apply seasonal fertilizer to lawn', 4, 6, 'drop.fill', 'Opacity', 'fertilizer,lawn,grass,seasonal', 42, true),
|
||||||
|
(43, NOW(), NOW(), 'Aerate Lawn', 'Aerate soil to improve grass health', 4, 8, 'square.grid.3x3.fill', 'GridOn', 'aerate,lawn,soil,grass', 43, true),
|
||||||
|
(44, NOW(), NOW(), 'Winterize Irrigation', 'Blow out sprinkler lines before winter', 4, 8, 'snowflake', 'AcUnit', 'irrigation,sprinkler,winterize,freeze', 44, true),
|
||||||
|
(45, NOW(), NOW(), 'Mulch Garden Beds', 'Add fresh mulch to garden beds', 4, 8, 'leaf.fill', 'Forest', 'mulch,garden,beds,landscaping', 45, true),
|
||||||
|
|
||||||
|
-- ELECTRICAL (category_id = 3)
|
||||||
|
(46, NOW(), NOW(), 'Test Surge Protectors', 'Check indicator lights on surge protectors', 3, 8, 'bolt.fill', 'ElectricBolt', 'surge,protector,electrical,safety', 46, true),
|
||||||
|
(47, NOW(), NOW(), 'Check Circuit Breaker Panel', 'Inspect for signs of damage or overheating', 3, 8, 'bolt.square.fill', 'ElectricalServices', 'circuit,breaker,panel,inspection', 47, true),
|
||||||
|
(48, NOW(), NOW(), 'Replace Outdoor Light Bulbs', 'Check and replace burned out exterior lights', 3, 6, 'lightbulb.fill', 'LightbulbOutline', 'light,bulb,outdoor,replacement', 48, true),
|
||||||
|
(49, NOW(), NOW(), 'Test Outdoor Lighting Timer', 'Verify timer settings and functionality', 3, 7, 'clock.fill', 'Schedule', 'timer,lighting,outdoor,test', 49, true),
|
||||||
|
|
||||||
|
-- INTERIOR (category_id = 7)
|
||||||
|
(50, NOW(), NOW(), 'Deep Clean Carpets', 'Steam clean or shampoo carpets', 7, 8, 'rectangle.and.hand.point.up.left.fill', 'Carpet', 'carpet,cleaning,steam,deep clean', 50, true),
|
||||||
|
(51, NOW(), NOW(), 'Clean Window Interiors', 'Wash interior window surfaces and tracks', 7, 6, 'window.horizontal', 'Window', 'window,interior,cleaning,tracks', 51, true),
|
||||||
|
(52, NOW(), NOW(), 'Check Caulking', 'Inspect and repair caulk around tubs, showers, sinks', 7, 8, 'sealant.fill', 'LineWeight', 'caulk,bathroom,kitchen,seal', 52, true),
|
||||||
|
(53, NOW(), NOW(), 'Lubricate Door Hinges', 'Apply lubricant to squeaky hinges', 7, 8, 'door.left.hand.closed', 'MeetingRoom', 'door,hinge,lubricant,squeak', 53, true),
|
||||||
|
(54, NOW(), NOW(), 'Touch Up Paint', 'Fix scuffs and marks on walls', 7, 8, 'paintbrush.fill', 'ImagesearchRoller', 'paint,walls,touch up,scuffs', 54, true),
|
||||||
|
(55, NOW(), NOW(), 'Replace Air Fresheners', 'Change out air fresheners throughout home', 7, 5, 'wind', 'Air', 'air freshener,scent,home,fragrance', 55, true),
|
||||||
|
|
||||||
|
-- SEASONAL (category_id = 5 General)
|
||||||
|
(56, NOW(), NOW(), 'Reverse Ceiling Fan Direction', 'Change rotation for heating vs cooling season', 5, 7, 'fanblades.fill', 'WindPower', 'ceiling fan,direction,seasonal,hvac', 56, true),
|
||||||
|
(57, NOW(), NOW(), 'Store/Retrieve Seasonal Items', 'Rotate seasonal decorations and equipment', 5, 7, 'archivebox.fill', 'Archive', 'seasonal,storage,decorations,rotation', 57, true),
|
||||||
|
(58, NOW(), NOW(), 'Check Weather Stripping', 'Inspect and replace worn weather stripping on doors/windows', 5, 8, 'wind', 'Air', 'weather stripping,door,window,insulation', 58, true),
|
||||||
|
(59, NOW(), NOW(), 'Service Snow Blower', 'Prepare snow blower for winter season', 5, 8, 'snowflake', 'AcUnit', 'snow blower,service,winter,maintenance', 59, true),
|
||||||
|
(60, NOW(), NOW(), 'Service Lawn Mower', 'Change oil, sharpen blade, replace spark plug', 5, 8, 'leaf.fill', 'Grass', 'lawn mower,service,maintenance,spring', 60, true)
|
||||||
|
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
category_id = EXCLUDED.category_id,
|
||||||
|
frequency_id = EXCLUDED.frequency_id,
|
||||||
|
icon_ios = EXCLUDED.icon_ios,
|
||||||
|
icon_android = EXCLUDED.icon_android,
|
||||||
|
tags = EXCLUDED.tags,
|
||||||
|
display_order = EXCLUDED.display_order,
|
||||||
|
is_active = EXCLUDED.is_active,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- Reset sequence
|
||||||
|
SELECT setval('task_tasktemplate_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_tasktemplate), false);
|
||||||
Reference in New Issue
Block a user