Files
acmcc/src/pages/ChecklistsPage.tsx
T
2026-06-01 20:19:26 -04:00

331 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast";
import { ListChecks, Plus, Search, MoreHorizontal, Edit, Trash2, Copy, ChevronDown, ChevronUp } from "lucide-react";
import RecordImportButton from "@/components/RecordImportButton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { Progress } from "@/components/ui/progress";
export default function ChecklistsPage() {
const { toast } = useToast();
const { user } = useAuth();
const [checklists, setChecklists] = useState<any[]>([]);
const [associations, setAssociations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [filterAssociation, setFilterAssociation] = useState("all");
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form, setForm] = useState({ title: "", description: "", association_id: "", items: [] as { text: string; done: boolean }[] });
const [newItem, setNewItem] = useState("");
const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set());
const fetchData = useCallback(async () => {
setLoading(true);
const [checklistRes, assocRes] = await Promise.all([
supabase.from("checklists").select("*, associations(name)").order("created_at", { ascending: false }),
supabase.from("associations").select("id, name").eq("status", "active").order("name"),
]);
setChecklists(checklistRes.data || []);
setAssociations(assocRes.data || []);
setLoading(false);
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const openNew = () => {
setEditing(null);
setForm({ title: "", description: "", association_id: associations[0]?.id || "", items: [] });
setNewItem("");
setDialogOpen(true);
};
const openEdit = (c: any) => {
setEditing(c);
setForm({ title: c.title, description: c.description || "", association_id: c.association_id, items: (c.items as any[]) || [] });
setNewItem("");
setDialogOpen(true);
};
const addItem = () => {
if (!newItem.trim()) return;
setForm({ ...form, items: [...form.items, { text: newItem.trim(), done: false }] });
setNewItem("");
};
const removeItem = (idx: number) => {
setForm({ ...form, items: form.items.filter((_, i) => i !== idx) });
};
const handleSave = async () => {
if (!form.title.trim()) { toast({ title: "Title is required", variant: "destructive" }); return; }
if (!form.association_id) { toast({ title: "Please select an association", variant: "destructive" }); return; }
const payload = { title: form.title, description: form.description || null, items: form.items };
if (editing) {
const { error } = await supabase.from("checklists").update(payload).eq("id", editing.id);
if (error) { toast({ title: "Error updating", description: error.message, variant: "destructive" }); return; }
toast({ title: "Checklist updated" });
} else {
const { error } = await supabase.from("checklists").insert({ ...payload, association_id: form.association_id, created_by: user?.id });
if (error) { toast({ title: "Error creating", description: error.message, variant: "destructive" }); return; }
toast({ title: "Checklist created" });
}
setDialogOpen(false);
fetchData();
};
const handleDelete = async (id: string) => {
const { error } = await supabase.from("checklists").delete().eq("id", id);
if (error) { toast({ title: "Error", description: error.message, variant: "destructive" }); return; }
toast({ title: "Checklist deleted" });
fetchData();
};
const handleDuplicate = async (c: any) => {
const { error } = await supabase.from("checklists").insert({
title: `${c.title} (Copy)`,
description: c.description,
association_id: c.association_id,
items: ((c.items as any[]) || []).map((i: any) => ({ ...i, done: false })),
created_by: user?.id,
});
if (error) { toast({ title: "Error", description: error.message, variant: "destructive" }); return; }
toast({ title: "Checklist duplicated" });
fetchData();
};
const toggleItem = async (checklistId: string, itemIdx: number) => {
const checklist = checklists.find(c => c.id === checklistId);
if (!checklist) return;
const items = [...((checklist.items as any[]) || [])];
items[itemIdx] = { ...items[itemIdx], done: !items[itemIdx].done };
// Optimistic update
setChecklists(prev => prev.map(c => c.id === checklistId ? { ...c, items } : c));
const allDone = items.length > 0 && items.every(i => i.done);
const { error } = await supabase.from("checklists").update({ items, status: allDone ? "completed" : "active" }).eq("id", checklistId);
if (error) {
fetchData(); // revert on error
toast({ title: "Error saving", description: error.message, variant: "destructive" });
}
};
const toggleExpanded = (id: string) => {
setExpandedCards(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const filtered = checklists.filter((c) => {
const matchesSearch = c.title.toLowerCase().includes(search.toLowerCase());
const matchesAssoc = filterAssociation === "all" || c.association_id === filterAssociation;
return matchesSearch && matchesAssoc;
});
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<ListChecks className="h-6 w-6 text-primary" /> Checklists
</h1>
<p className="text-sm text-muted-foreground mt-1">Create and manage reusable checklists with trackable items.</p>
</div>
<div className="flex gap-2">
<RecordImportButton
title="Import Checklists"
description="Upload a CSV or Excel file with checklist records."
expectedColumns={[
{ key: "title", label: "Title", required: true },
{ key: "description", label: "Description" },
]}
onImport={async (rows) => {
if (!associations.length) throw new Error("Create an association first");
const payload = rows.map(r => ({
title: r.title || "Untitled",
description: r.description || null,
association_id: associations[0].id,
created_by: user?.id,
}));
const { error } = await supabase.from("checklists").insert(payload);
if (error) throw error;
toast({ title: `Imported ${rows.length} checklists` });
fetchData();
}}
templateFileName="checklists_template.xlsx"
/>
<Button className="gap-2" onClick={openNew}><Plus className="h-4 w-4" /> New Checklist</Button>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search checklists..." className="pl-10" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Select value={filterAssociation} onValueChange={setFilterAssociation}>
<SelectTrigger className="w-[220px]"><SelectValue placeholder="All Associations" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Associations</SelectItem>
{associations.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{loading ? (
<div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>
) : filtered.length === 0 ? (
<Card><CardContent className="py-12 text-center text-muted-foreground">No checklists found. Create one to get started.</CardContent></Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filtered.map((c) => {
const items: { text: string; done: boolean }[] = (c.items as any[]) || [];
const done = items.filter(i => i.done).length;
const progress = items.length > 0 ? Math.round((done / items.length) * 100) : 0;
const expanded = expandedCards.has(c.id);
return (
<Card key={c.id} className="group hover:shadow-md transition-shadow flex flex-col">
<CardHeader className="pb-2 flex flex-row items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="text-sm truncate">{c.title}</CardTitle>
{c.associations?.name && (
<p className="text-xs text-muted-foreground mt-0.5">{c.associations.name}</p>
)}
{c.description && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{c.description}</p>}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEdit(c)}><Edit className="h-4 w-4 mr-2" /> Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicate(c)}><Copy className="h-4 w-4 mr-2" /> Duplicate</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => handleDelete(c.id)}><Trash2 className="h-4 w-4 mr-2" /> Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
<CardContent className="pt-0 flex-1 flex flex-col">
<div className="flex items-center gap-2 mb-2">
<Progress value={progress} className="h-2 flex-1" />
<span className="text-xs text-muted-foreground whitespace-nowrap">{done}/{items.length}</span>
{c.status === "completed" && <Badge className="text-[10px] px-1.5 py-0">Done</Badge>}
</div>
{items.length > 0 && (
<>
<Button
variant="ghost"
size="sm"
className="w-full justify-between text-xs text-muted-foreground h-7 px-1"
onClick={() => toggleExpanded(c.id)}
>
{expanded ? "Hide items" : "Show items"}
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</Button>
{expanded && (
<div className="space-y-1.5 mt-1 max-h-48 overflow-y-auto">
{items.map((item, idx) => (
<div key={idx} className="flex items-center gap-2">
<Checkbox
checked={item.done}
onCheckedChange={() => toggleItem(c.id, idx)}
/>
<span className={`text-xs flex-1 ${item.done ? "line-through text-muted-foreground" : "text-foreground"}`}>
{item.text}
</span>
</div>
))}
</div>
)}
</>
)}
</CardContent>
</Card>
);
})}
</div>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editing ? "Edit" : "New"} Checklist</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{!editing && (
<div>
<Label>Association *</Label>
<Select value={form.association_id} onValueChange={(v) => setForm({ ...form, association_id: v })}>
<SelectTrigger><SelectValue placeholder="Select association" /></SelectTrigger>
<SelectContent>
{associations.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
)}
<div>
<Label>Title *</Label>
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="Checklist name" />
</div>
<div>
<Label>Description</Label>
<Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Optional description" rows={2} />
</div>
<div>
<Label>Checklist Items</Label>
<div className="space-y-2 mt-2 max-h-48 overflow-y-auto">
{form.items.map((item, idx) => (
<div key={idx} className="flex items-center gap-2">
<Checkbox
checked={item.done}
onCheckedChange={(c) => {
const items = [...form.items];
items[idx] = { ...items[idx], done: !!c };
setForm({ ...form, items });
}}
/>
<span className={`text-sm flex-1 ${item.done ? "line-through text-muted-foreground" : ""}`}>{item.text}</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeItem(idx)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
<div className="flex gap-2 mt-2">
<Input
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
placeholder="Add an item..."
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addItem(); } }}
/>
<Button size="sm" onClick={addItem} type="button">Add</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave}>{editing ? "Update" : "Create"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}