Add actionable push notifications and fix recurring task completion

Features:
- Add task action buttons to push notifications (complete, view, cancel, etc.)
- Add button types logic for different task states (overdue, in_progress, etc.)
- Implement Chain of Responsibility pattern for task categorization
- Add comprehensive kanban categorization documentation

Fixes:
- Reset recurring task status to Pending after completion so tasks appear
  in correct kanban column (was staying in "In Progress")
- Fix PostgreSQL EXTRACT function error in overdue notifications query
- Update seed data to properly set next_due_date for recurring tasks

Admin:
- Add tasks list to residence detail page
- Fix task edit page to properly handle all fields

🤖 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 14:23:14 -06:00
parent bbf3999c79
commit 1b06c0639c
22 changed files with 2715 additions and 142 deletions
@@ -3,10 +3,10 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Trash2, Pencil } from 'lucide-react';
import { ArrowLeft, Trash2, Pencil, ExternalLink } from 'lucide-react';
import { toast } from 'sonner';
import { residencesApi } from '@/lib/api';
import { residencesApi, tasksApi } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
@@ -17,6 +17,14 @@ import {
CardTitle,
} from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
export function ResidenceDetailClient() {
const params = useParams();
@@ -30,6 +38,12 @@ export function ResidenceDetailClient() {
enabled: !!residenceId,
});
const { data: tasksData, isLoading: isLoadingTasks } = useQuery({
queryKey: ['residence-tasks', residenceId],
queryFn: () => tasksApi.list({ residence_id: residenceId, per_page: 100 }),
enabled: !!residenceId,
});
const deleteMutation = useMutation({
mutationFn: () => residencesApi.delete(residenceId),
onSuccess: () => {
@@ -217,6 +231,70 @@ export function ResidenceDetailClient() {
</CardContent>
</Card>
</div>
{/* Tasks */}
<Card>
<CardHeader>
<CardTitle>Tasks</CardTitle>
<CardDescription>All tasks for this property</CardDescription>
</CardHeader>
<CardContent>
{isLoadingTasks ? (
<div className="text-muted-foreground">Loading tasks...</div>
) : !tasksData?.data || tasksData.data.length === 0 ? (
<div className="text-muted-foreground">No tasks for this property</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Category</TableHead>
<TableHead>Due Date</TableHead>
<TableHead>Created By</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasksData.data.map((task) => (
<TableRow key={task.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{task.title}
{task.is_cancelled && (
<Badge variant="destructive" className="text-xs">Cancelled</Badge>
)}
{task.is_archived && (
<Badge variant="secondary" className="text-xs">Archived</Badge>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{task.status_name || '-'}</Badge>
</TableCell>
<TableCell>{task.priority_name || '-'}</TableCell>
<TableCell>{task.category_name || '-'}</TableCell>
<TableCell>
{task.due_date
? new Date(task.due_date).toLocaleDateString()
: '-'}
</TableCell>
<TableCell>{task.created_by_name}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" asChild>
<Link href={`/tasks/${task.id}`}>
<ExternalLink className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
@@ -84,8 +84,11 @@ export default function EditTaskPage() {
const [formInitialized, setFormInitialized] = useState(false);
// Wait for ALL data including lookups before initializing form
const lookupsLoaded = !categoriesLoading && !prioritiesLoading && !statusesLoading && !frequenciesLoading;
useEffect(() => {
if (task && !formInitialized) {
if (task && lookupsLoaded && !formInitialized) {
setFormData({
residence_id: task.residence_id,
created_by_id: task.created_by_id,
@@ -106,9 +109,9 @@ export default function EditTaskPage() {
});
setFormInitialized(true);
}
}, [task, formInitialized]);
}, [task, lookupsLoaded, formInitialized]);
const isDataLoading = taskLoading || usersLoading || residencesLoading || categoriesLoading || prioritiesLoading || statusesLoading || frequenciesLoading || !formInitialized;
const isDataLoading = taskLoading || usersLoading || residencesLoading || !lookupsLoaded || !formInitialized;
const updateMutation = useMutation({
mutationFn: (data: UpdateTaskRequest) => tasksApi.update(taskId, data),
@@ -322,8 +325,14 @@ export default function EditTaskPage() {
<div className="space-y-2">
<Label htmlFor="status_id">Status</Label>
<Select
value={formData.status_id?.toString() || 'none'}
onValueChange={(value) => updateField('status_id', value === 'none' ? undefined : Number(value))}
value={formData.status_id !== undefined ? formData.status_id.toString() : 'none'}
onValueChange={(value) => {
const newValue = value === 'none' ? undefined : Number(value);
// Only update if actually different (prevents spurious triggers)
if (newValue !== formData.status_id) {
updateField('status_id', newValue);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />