Files
starr-philproperties/src/modules/admin/pages/achievements/Achievements.jsx
T
kennethobsequio 7e964f2432 add: more commits
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-03 16:21:27 +08:00

173 lines
7.9 KiB
React

import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import * as LucideIcons from "lucide-react";
import { House, Plus, Pencil, Trash2, Trophy, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminAchievementsProvider,
useAdminAchievements,
} from "@/contexts/AdminAchievementsContext";
function AchievementCard({ item, onEdit, onDelete }) {
const Icon = LucideIcons[item.icon] ?? Trophy;
return (
<div className="rounded-lg border bg-card p-5 flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-lg border bg-muted flex items-center justify-center shrink-0">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold">{item.label}</p>
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{item.key}</code>
<Badge variant="outline" className="text-[10px] capitalize">{item.type}</Badge>
{!item.is_active && <Badge variant="secondary">Inactive</Badge>}
{item.is_system && (
<Badge variant="secondary" className="gap-1">
<Lock className="h-2.5 w-2.5" /> System
</Badge>
)}
</div>
{item.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.description}</p>
)}
{item.trigger && (
<p className="text-xs text-muted-foreground mt-0.5">
Trigger: <span className="font-medium text-foreground capitalize">{item.trigger}</span>
</p>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
{!item.is_system && (
<Button type="button" variant="ghost" size="icon" onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
);
}
function AchievementsInner() {
const navigate = useNavigate();
const { achievements, loading, fetchAchievements, deleteAchievement } = useAdminAchievements();
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
useEffect(() => { fetchAchievements(); }, []);
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
await deleteAchievement(deleteTarget.achievement_definition_id);
setDeleting(false);
setDeleteTarget(null);
};
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Achievements - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Achievements" },
]} />
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold">Achievements</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Badges and milestones learners can earn across the platform.
</p>
</div>
<Button size="sm" onClick={() => navigate("/admin/achievements/add")}>
<Plus className="h-4 w-4 mr-2" />
Add Achievement
</Button>
</div>
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-xs text-muted-foreground">
<strong>System</strong> achievements are auto-granted by platform events (registration, course completion, etc.)
and cannot be deleted or have their key/type changed — everything else stays editable.
</p>
</div>
<Separator className="mb-5" />
{loading && !achievements.length ? (
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
) : !achievements.length ? (
<p className="text-sm text-muted-foreground text-center py-12">No achievements found.</p>
) : (
<div className="space-y-3">
{achievements.map((item) => (
<AchievementCard
key={item.achievement_definition_id}
item={item}
onEdit={(a) => navigate(`/admin/achievements/${a.achievement_definition_id}/edit`)}
onDelete={(a) => setDeleteTarget(a)}
/>
))}
</div>
)}
</div>
</div>
{/* Delete confirmation dialog */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete Achievement</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{deleteTarget?.label}</span>?
This action cannot be undone. Any courses referencing this achievement must be unassigned first.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
{deleting && <Spinner className="h-4 w-4 mr-2" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
export default function Achievements() {
return (
<AdminAchievementsProvider>
<AchievementsInner />
</AdminAchievementsProvider>
);
}