mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
331 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|